birdclaw 0.7.0 → 0.8.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 0.8.0 - 2026-06-10
4
+
5
+ ### Added
6
+
7
+ - Add localized Today/digest reports through `--language <locale-id>`, `BIRDCLAW_DIGEST_LANGUAGE`, and the period-digest API, with canonical locale validation and separate caches. (#47 - thanks @yujiawei)
8
+
9
+ ### Changed
10
+
11
+ - Update runtime and development dependencies, align TanStack Start packages, and move pnpm's native build allowlist into workspace configuration.
12
+
13
+ ### Fixed
14
+
15
+ - Add accurate archive-first Sign in and archive-request onboarding, including account-binding requirements, current xurl/bird setup, scoped transport selection, and clean autolink rendering. (#46 - thanks @peetzweg)
16
+ - Respect `OPENAI_BASE_URL` when sending Today/digest requests to OpenAI-compatible API endpoints.
17
+ - Anchor Link Insights' Today range to UTC midnight so it matches stored `created_at` timestamps across local time zones.
18
+
3
19
  ## 0.7.0 - 2026-06-01
4
20
 
5
21
  ### Added
package/README.md CHANGED
@@ -219,6 +219,8 @@ birdclaw auth status --json
219
219
  birdclaw db stats --json
220
220
  ```
221
221
 
222
+ `auth status` reports Birdclaw's coarse xurl status. Verify xurl with `xurl whoami` and bird with `bird whoami`. For setup and transport selection, see [Sign in](docs/auth.md).
223
+
222
224
  Find and import an archive:
223
225
 
224
226
  ```bash
@@ -227,6 +229,8 @@ birdclaw import archive --json
227
229
  birdclaw import archive ~/Downloads/twitter-archive-2025.zip --json
228
230
  ```
229
231
 
232
+ Don't have an archive yet? Request it from <https://x.com/settings/download_your_data>; X emails a download link when it is ready, which may take a few days. A fresh Birdclaw database needs the archive import to establish account identity before live sync. See [Archive Import → Get an archive](docs/archive.md#get-an-archive).
233
+
230
234
  Optional profile hydration can improve bios, follower counts, and avatars, but it performs live X profile reads and can spend API credits on large archives:
231
235
 
232
236
  ```bash
@@ -414,17 +418,18 @@ birdclaw profile-analyse openai --max-pages 20 --max-conversations 40 --conversa
414
418
 
415
419
  ### What happened today
416
420
 
417
- `birdclaw today` streams a local "what happened" digest from the SQLite store. It uses the OpenAI Responses API with `gpt-5.5`, medium reasoning, and priority service tier by default. Set `OPENAI_API_KEY`; override with `BIRDCLAW_AI_MODEL`, `BIRDCLAW_OPENAI_REASONING_EFFORT`, or `BIRDCLAW_OPENAI_SERVICE_TIER` when needed.
421
+ `birdclaw today` streams a local "what happened" digest from the SQLite store. It uses the OpenAI Responses API with `gpt-5.5`, medium reasoning, and priority service tier by default. Set `OPENAI_API_KEY`; override with `BIRDCLAW_AI_MODEL`, `BIRDCLAW_OPENAI_REASONING_EFFORT`, or `BIRDCLAW_OPENAI_SERVICE_TIER` when needed. Use `--language <locale-id>` or `BIRDCLAW_DIGEST_LANGUAGE` for localized reports.
418
422
 
419
423
  ```bash
420
424
  birdclaw today
425
+ birdclaw today --language zh-CN
421
426
  birdclaw digest 24h --refresh
422
427
  birdclaw digest week --json
423
428
  birdclaw digest --since 2026-05-16T00:00:00Z --until 2026-05-17T00:00:00Z
424
429
  birdclaw digest today --include-dms
425
430
  ```
426
431
 
427
- The web UI exposes the same stream under `What happened`. DMs are excluded unless explicitly enabled. Final structured results are cached by the exact local context hash, model, reasoning effort, and service tier.
432
+ The web UI exposes the same stream under `What happened`. DMs are excluded unless explicitly enabled. Final structured results are cached by the exact local context hash, model, reasoning effort, service tier, and report language.
428
433
 
429
434
  ### Search and triage DMs
430
435
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "birdclaw",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Local Twitter memory in SQLite for archives, DMs, likes, bookmarks, and moderation",
5
5
  "homepage": "https://github.com/steipete/birdclaw#readme",
6
6
  "license": "MIT",
@@ -56,53 +56,47 @@
56
56
  "@tailwindcss/vite": "^4.3.0",
57
57
  "@tanstack/devtools-vite": "0.7.0",
58
58
  "@tanstack/react-devtools": "0.10.5",
59
- "@tanstack/react-router": "1.169.2",
60
- "@tanstack/react-router-devtools": "1.166.13",
61
- "@tanstack/react-router-ssr-query": "1.166.12",
62
- "@tanstack/react-start": "1.167.65",
63
- "@tanstack/router-plugin": "^1.167.35",
64
- "@vitejs/plugin-react": "^6.0.1",
65
- "commander": "^14.0.3",
66
- "effect": "^3.21.2",
67
- "kysely": "^0.29.0",
68
- "lucide-react": "^1.16.0",
59
+ "@tanstack/react-router": "1.170.15",
60
+ "@tanstack/react-router-devtools": "1.167.0",
61
+ "@tanstack/react-router-ssr-query": "1.167.1",
62
+ "@tanstack/react-start": "1.168.25",
63
+ "@tanstack/router-plugin": "^1.168.18",
64
+ "@vitejs/plugin-react": "^6.0.2",
65
+ "commander": "^15.0.0",
66
+ "effect": "^3.21.3",
67
+ "kysely": "^0.29.2",
68
+ "lucide-react": "^1.17.0",
69
69
  "mapbox-gl": "^3.24.0",
70
- "react": "^19.2.6",
71
- "react-dom": "^19.2.6",
70
+ "react": "^19.2.7",
71
+ "react-dom": "^19.2.7",
72
72
  "react-map-gl": "^8.1.1",
73
73
  "supercluster": "^8.0.1",
74
74
  "tailwindcss": "^4.3.0",
75
- "tsx": "^4.22.0",
76
- "vite": "^8.0.13",
75
+ "tsx": "^4.22.4",
76
+ "vite": "^8.0.16",
77
77
  "zod": "^4.4.3"
78
78
  },
79
79
  "devDependencies": {
80
80
  "@playwright/test": "^1.60.0",
81
- "@tailwindcss/typography": "^0.5.19",
81
+ "@tailwindcss/typography": "^0.5.20",
82
82
  "@testing-library/dom": "^10.4.1",
83
83
  "@testing-library/jest-dom": "^6.9.1",
84
84
  "@testing-library/react": "^16.3.2",
85
85
  "@types/geojson": "^7946.0.16",
86
- "@types/node": "^25.8.0",
87
- "@types/react": "^19.2.14",
86
+ "@types/node": "^25.9.2",
87
+ "@types/react": "^19.2.17",
88
88
  "@types/react-dom": "^19.2.3",
89
89
  "@types/supercluster": "^7.1.3",
90
- "@typescript/native-preview": "7.0.0-dev.20260514.1",
91
- "@vitest/coverage-v8": "^4.1.6",
90
+ "@typescript/native-preview": "7.0.0-dev.20260609.1",
91
+ "@vitest/coverage-v8": "^4.1.8",
92
92
  "jsdom": "^29.1.1",
93
- "oxfmt": "^0.49.0",
94
- "oxlint": "^1.64.0",
93
+ "oxfmt": "^0.54.0",
94
+ "oxlint": "^1.69.0",
95
95
  "typescript": "^6.0.3",
96
- "vitest": "^4.1.6"
96
+ "vitest": "^4.1.8"
97
97
  },
98
98
  "engines": {
99
99
  "node": ">=25.8.1 <27"
100
100
  },
101
- "packageManager": "pnpm@10.31.0",
102
- "pnpm": {
103
- "onlyBuiltDependencies": [
104
- "esbuild",
105
- "lightningcss"
106
- ]
107
- }
101
+ "packageManager": "pnpm@10.31.0"
108
102
  }
@@ -24,7 +24,10 @@ const productDescription =
24
24
  const brewInstall = "brew install steipete/tap/birdclaw";
25
25
 
26
26
  const sections = [
27
- ["Start", ["index.md", "install.md", "quickstart.md", "configuration.md"]],
27
+ [
28
+ "Start",
29
+ ["index.md", "install.md", "auth.md", "quickstart.md", "configuration.md"],
30
+ ],
28
31
  [
29
32
  "Archive & Sync",
30
33
  ["archive.md", "sync.md", "media.md", "backup.md", "jobs.md"],
@@ -449,19 +452,41 @@ function inline(text, currentRel) {
449
452
  stash.push(`<code>${escapeHtml(code)}</code>`);
450
453
  return `\u0000${stash.length - 1}\u0000`;
451
454
  });
452
- out = escapeHtml(out)
453
- .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
454
- .replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, "$1<em>$2</em>")
455
- .replace(/(^|[^_])_([^_\s][^_]*?)_(?!_)/g, "$1<em>$2</em>")
456
- .replace(
457
- /\[([^\]]+)\]\(([^)]+)\)/g,
458
- (_, label, href) =>
459
- `<a href="${escapeAttr(rewriteHref(href, currentRel))}">${label}</a>`,
460
- )
461
- .replace(/&lt;(https?:\/\/[^\s<>]+)&gt;/g, '<a href="$1">$1</a>');
455
+ const stashLink = (label, href) => {
456
+ stash.push(
457
+ `<a href="${escapeAttr(rewriteHref(href, currentRel))}">${renderInlineText(label, stash)}</a>`,
458
+ );
459
+ return `\u0000${stash.length - 1}\u0000`;
460
+ };
461
+ out = out.replace(/\[([^\]]+)\]\(<([^<>]+)>\)/g, (_, label, href) =>
462
+ stashLink(label, href),
463
+ );
464
+ out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) =>
465
+ stashLink(label, href),
466
+ );
467
+ out = out.replace(/<(https?:\/\/[^\s<>]+)>/g, (_, url) => {
468
+ stash.push(`<a href="${escapeAttr(url)}">${escapeHtml(url)}</a>`);
469
+ return `\u0000${stash.length - 1}\u0000`;
470
+ });
471
+ return renderInlineText(out, stash);
472
+ }
473
+
474
+ function renderInlineText(text, stash) {
475
+ let out = formatInlineText(text);
462
476
  out = out.replace(/\\\|/g, "|");
463
477
  out = out.replace(/&lt;br&gt;/g, "<br>");
464
- return out.replace(/\u0000(\d+)\u0000/g, (_, i) => stash[Number(i)]);
478
+ return restoreInlineStash(out, stash);
479
+ }
480
+
481
+ function formatInlineText(text) {
482
+ return escapeHtml(text)
483
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
484
+ .replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, "$1<em>$2</em>")
485
+ .replace(/(^|[^_])_([^_\s][^_]*?)_(?!_)/g, "$1<em>$2</em>");
486
+ }
487
+
488
+ function restoreInlineStash(text, stash) {
489
+ return text.replace(/\u0000(\d+)\u0000/g, (_, i) => stash[Number(i)]);
465
490
  }
466
491
 
467
492
  function rewriteHref(href, currentRel) {
@@ -886,6 +911,8 @@ function highlightYamlValue(rest) {
886
911
  return escapeHtml(rest);
887
912
  }
888
913
 
914
+ export const __test__ = { inline };
915
+
889
916
  function validateLinks(outputDir) {
890
917
  const failures = [];
891
918
  const placeholderHrefs =
package/src/cli.ts CHANGED
@@ -63,6 +63,7 @@ import {
63
63
  syncMentions,
64
64
  } from "#/lib/mentions-live";
65
65
  import {
66
+ normalizeDigestLanguage,
66
67
  streamPeriodDigest,
67
68
  type PeriodDigestOptions,
68
69
  type PeriodDigestPreset,
@@ -371,6 +372,7 @@ function buildDigestOptions(
371
372
  account?: string;
372
373
  includeDms?: boolean;
373
374
  model?: string;
375
+ language?: string;
374
376
  refresh?: boolean;
375
377
  since?: string;
376
378
  until?: string;
@@ -398,6 +400,14 @@ function buildDigestOptions(
398
400
  if (liveSyncMode === undefined) {
399
401
  return null;
400
402
  }
403
+ let language: string | undefined;
404
+ try {
405
+ language = normalizeDigestLanguage(options.language);
406
+ } catch (error) {
407
+ printError(error instanceof Error ? error.message : String(error));
408
+ process.exitCode = 1;
409
+ return null;
410
+ }
401
411
  return {
402
412
  period: parseDigestPeriod(period),
403
413
  since: options.since,
@@ -406,6 +416,7 @@ function buildDigestOptions(
406
416
  includeDms: Boolean(options.includeDms),
407
417
  refresh: Boolean(options.refresh),
408
418
  model: options.model,
419
+ language,
409
420
  maxTweets,
410
421
  maxLinks,
411
422
  liveSync: options.liveSync !== false,
@@ -1268,6 +1279,10 @@ program
1268
1279
  .option("--account <accountId>", "Account id")
1269
1280
  .option("--include-dms", "Include private DM context")
1270
1281
  .option("--model <model>", "OpenAI model id")
1282
+ .option(
1283
+ "--language <tag>",
1284
+ "Report language as a Unicode locale id, e.g. zh-CN (env: BIRDCLAW_DIGEST_LANGUAGE)",
1285
+ )
1271
1286
  .option("--refresh", "Bypass the local digest cache")
1272
1287
  .option("--max-tweets <n>", "Maximum tweet context", "5000")
1273
1288
  .option("--max-links <n>", "Maximum linked articles", "12")
@@ -1292,6 +1307,10 @@ program
1292
1307
  .option("--since <isoDate>", "Start of explicit window")
1293
1308
  .option("--until <isoDate>", "End of explicit window")
1294
1309
  .option("--model <model>", "OpenAI model id")
1310
+ .option(
1311
+ "--language <tag>",
1312
+ "Report language as a Unicode locale id, e.g. zh-CN (env: BIRDCLAW_DIGEST_LANGUAGE)",
1313
+ )
1295
1314
  .option("--refresh", "Bypass the local digest cache")
1296
1315
  .option("--max-tweets <n>", "Maximum tweet context", "5000")
1297
1316
  .option("--max-links <n>", "Maximum linked articles", "12")
@@ -380,6 +380,8 @@ function resolveRange(
380
380
 
381
381
  const start = new Date(now);
382
382
  if (range === "today") {
383
+ // "Today" follows the user's local calendar day; toISOString below
384
+ // converts that local midnight into the correct UTC storage boundary.
383
385
  start.setHours(0, 0, 0, 0);
384
386
  } else {
385
387
  const days = range === "week" ? 7 : range === "month" ? 30 : 365;
@@ -28,6 +28,7 @@ export interface PeriodDigestOptions {
28
28
  includeDms?: boolean;
29
29
  refresh?: boolean;
30
30
  model?: string;
31
+ language?: string;
31
32
  reasoningEffort?: "minimal" | "low" | "medium" | "high";
32
33
  serviceTier?: "default" | "flex" | "priority";
33
34
  signal?: AbortSignal;
@@ -108,6 +109,32 @@ const PeriodDigestSchema = z.object({
108
109
  sourceTweetIds: z.array(z.string()).default([]),
109
110
  });
110
111
 
112
+ const MAX_DIGEST_LANGUAGE_LENGTH = 64;
113
+
114
+ export function normalizeDigestLanguage(
115
+ value: string | undefined,
116
+ ): string | undefined {
117
+ const trimmed = value?.trim();
118
+ if (!trimmed) return undefined;
119
+ if (
120
+ trimmed.length > MAX_DIGEST_LANGUAGE_LENGTH ||
121
+ !/^[a-z0-9]+(?:-[a-z0-9]+)*$/i.test(trimmed)
122
+ ) {
123
+ throw new Error(
124
+ "Digest language must be a valid Unicode locale identifier such as en, zh-CN, or pt-BR",
125
+ );
126
+ }
127
+ try {
128
+ const [canonical] = Intl.getCanonicalLocales(trimmed);
129
+ if (!canonical) throw new Error("missing canonical locale");
130
+ return canonical;
131
+ } catch {
132
+ throw new Error(
133
+ "Digest language must be a valid Unicode locale identifier such as en, zh-CN, or pt-BR",
134
+ );
135
+ }
136
+ }
137
+
111
138
  export type PeriodDigest = z.infer<typeof PeriodDigestSchema>;
112
139
 
113
140
  interface CompactTweet {
@@ -563,6 +590,12 @@ export function collectPeriodDigestContext(
563
590
  };
564
591
  }
565
592
 
593
+ function languageFromOptions(options: PeriodDigestOptions) {
594
+ return normalizeDigestLanguage(
595
+ options.language ?? process.env.BIRDCLAW_DIGEST_LANGUAGE,
596
+ );
597
+ }
598
+
566
599
  function modelFromOptions(options: PeriodDigestOptions) {
567
600
  return options.model ?? process.env.BIRDCLAW_AI_MODEL ?? DEFAULT_MODEL;
568
601
  }
@@ -834,16 +867,23 @@ function digestCacheKey(
834
867
  context: PeriodDigestContext,
835
868
  options: PeriodDigestOptions,
836
869
  ) {
837
- return [
870
+ const parts = [
838
871
  "period-digest:v2",
839
872
  modelFromOptions(options),
840
873
  reasoningEffortFromOptions(options),
841
874
  serviceTierFromOptions(options),
842
875
  context.hash,
843
- ].join(":");
876
+ ];
877
+ const lang = languageFromOptions(options);
878
+ if (lang) parts.push(`lang:${lang}`);
879
+ return parts.join(":");
844
880
  }
845
881
 
846
- function buildPrompt(context: PeriodDigestContext) {
882
+ function buildPrompt(
883
+ context: PeriodDigestContext,
884
+ options?: { language?: string },
885
+ ) {
886
+ const language = normalizeDigestLanguage(options?.language);
847
887
  const promptTweets = context.tweets.map((tweet) => ({
848
888
  id: tweet.id,
849
889
  url: tweet.url,
@@ -928,7 +968,7 @@ Requirements:
928
968
  - Stream one readable Markdown report first. The UI will show this text directly; do not rely on separate cards or structured summaries.
929
969
  - Target 700-1100 words when there is enough data.
930
970
  - Start with a 2-3 sentence lead that immediately says what people are talking about.
931
- - Use sections named "What people are talking about", "Important links shared", and "Worth opening". Add "Worth replying to" only if there are clearly high-signal replies.
971
+ - Use sections named "What people are talking about", "Important links shared", and "Worth opening". Add "Worth replying to" only if there are clearly high-signal replies. Translate these section titles when a report language is requested.
932
972
  - When a tweet has replyToTweet, use that parent context to understand what the author was replying to and whether Peter already joined the conversation.
933
973
  - Use bullets under each section. Each bullet should be specific and explain why it matters.
934
974
  - For tweets: cite every claim with inline tweet ids at the end of the relevant sentence or bullet, e.g. (tweet_123, tweet_456). These citations become hoverable source links.
@@ -942,6 +982,7 @@ Requirements:
942
982
  - Keep actionItems empty unless you wrote a "Worth replying to" section.
943
983
  - Put every tweet id cited in the Markdown into sourceTweetIds.
944
984
  - JSON shape: { "title": string, "summary": string, "keyTopics": [{ "title": string, "summary": string, "tweetIds": string[], "handles": string[] }], "notableLinks": [{ "title": string, "url": string, "why": string, "sourceTweetIds": string[] }], "people": [{ "handle": string, "name"?: string, "why": string }], "actionItems": [{ "kind": "reply"|"follow_up"|"read"|"sync", "label": string, "tweetId"?: string, "dmConversationId"?: string }], "sourceTweetIds": string[] }
985
+ ${language ? `- Write all human-readable prose, including section titles and JSON prose fields, in ${language}.\n- Preserve handles, URLs, tweet ids, and JSON property names exactly.` : ""}
945
986
 
946
987
  Dataset:
947
988
  ${JSON.stringify(dataset)}`;
@@ -950,12 +991,22 @@ ${JSON.stringify(dataset)}`;
950
991
  function fallbackDigest(
951
992
  context: PeriodDigestContext,
952
993
  markdown: string,
994
+ language?: string,
953
995
  ): PeriodDigest {
954
- const title = `${context.window.label} digest`;
996
+ const normalized = markdown.replaceAll(/\s+/g, " ").trim();
997
+ const heading = markdown
998
+ .split("\n")
999
+ .map((line) => line.match(/^#{1,6}\s+(.+)$/)?.[1]?.trim())
1000
+ .find(Boolean);
1001
+ const neutralFallback = language ? `[${language}]` : undefined;
955
1002
  return {
956
- title,
1003
+ title:
1004
+ heading?.slice(0, 160) ??
1005
+ neutralFallback ??
1006
+ `${context.window.label} digest`,
957
1007
  summary:
958
- markdown.replaceAll(/\s+/g, " ").trim().slice(0, 280) ||
1008
+ normalized.slice(0, 280) ||
1009
+ neutralFallback ||
959
1010
  "No model summary was returned.",
960
1011
  keyTopics: [],
961
1012
  notableLinks: [],
@@ -968,6 +1019,7 @@ function fallbackDigest(
968
1019
  function parseDigestFromHybridText(
969
1020
  context: PeriodDigestContext,
970
1021
  rawText: string,
1022
+ language?: string,
971
1023
  ): { digest: PeriodDigest; markdown: string } {
972
1024
  const [markdownPart, jsonPart] = rawText.split(DELIMITER_PATTERN);
973
1025
  const markdown = (markdownPart ?? rawText).trim();
@@ -982,10 +1034,13 @@ function parseDigestFromHybridText(
982
1034
  digest: PeriodDigestSchema.parse(JSON.parse(candidate)),
983
1035
  };
984
1036
  } catch {
985
- return { markdown, digest: fallbackDigest(context, markdown) };
1037
+ return {
1038
+ markdown,
1039
+ digest: fallbackDigest(context, markdown, language),
1040
+ };
986
1041
  }
987
1042
  }
988
- return { markdown, digest: fallbackDigest(context, markdown) };
1043
+ return { markdown, digest: fallbackDigest(context, markdown, language) };
989
1044
  }
990
1045
 
991
1046
  function emitVisibleDelta(
@@ -1129,7 +1184,9 @@ function createOpenAIRequestBody(
1129
1184
  },
1130
1185
  {
1131
1186
  role: "user",
1132
- content: buildPrompt(context),
1187
+ content: buildPrompt(context, {
1188
+ language: languageFromOptions(options),
1189
+ }),
1133
1190
  },
1134
1191
  ],
1135
1192
  };
@@ -1172,7 +1229,11 @@ function readOpenAIStreamEffect(
1172
1229
  }
1173
1230
 
1174
1231
  const parsed = yield* tryDigestSync(() =>
1175
- parseDigestFromHybridText(context, state.rawText),
1232
+ parseDigestFromHybridText(
1233
+ context,
1234
+ state.rawText,
1235
+ languageFromOptions(options),
1236
+ ),
1176
1237
  );
1177
1238
  const cacheKey = digestCacheKey(context, options);
1178
1239
  const updatedAt = yield* tryDigestSync(() =>
@@ -1213,16 +1274,20 @@ export function streamPeriodDigestEffect(
1213
1274
  handlers: PeriodDigestStreamHandlers = {},
1214
1275
  ): Effect.Effect<PeriodDigestRunResult, Error> {
1215
1276
  return Effect.gen(function* () {
1277
+ const resolvedOptions = {
1278
+ ...options,
1279
+ language: yield* tryDigestSync(() => languageFromOptions(options)),
1280
+ };
1216
1281
  yield* refreshPeriodDigestInputsEffect(
1217
- options,
1282
+ resolvedOptions,
1218
1283
  { threads: false },
1219
1284
  handlers,
1220
1285
  ).pipe(Effect.catchAll(() => Effect.void));
1221
1286
  let context = yield* tryDigestSync(() =>
1222
- collectPeriodDigestContext(options),
1287
+ collectPeriodDigestContext(resolvedOptions),
1223
1288
  );
1224
- let cacheKey = digestCacheKey(context, options);
1225
- const cached = options.refresh
1289
+ let cacheKey = digestCacheKey(context, resolvedOptions);
1290
+ const cached = resolvedOptions.refresh
1226
1291
  ? null
1227
1292
  : yield* tryDigestSync(() =>
1228
1293
  readSyncCache<{
@@ -1253,7 +1318,7 @@ export function streamPeriodDigestEffect(
1253
1318
  }
1254
1319
 
1255
1320
  yield* refreshPeriodDigestInputsEffect(
1256
- options,
1321
+ resolvedOptions,
1257
1322
  {
1258
1323
  timeline: false,
1259
1324
  mentions: false,
@@ -1264,8 +1329,10 @@ export function streamPeriodDigestEffect(
1264
1329
  },
1265
1330
  handlers,
1266
1331
  ).pipe(Effect.catchAll(() => Effect.void));
1267
- context = yield* tryDigestSync(() => collectPeriodDigestContext(options));
1268
- cacheKey = digestCacheKey(context, options);
1332
+ context = yield* tryDigestSync(() =>
1333
+ collectPeriodDigestContext(resolvedOptions),
1334
+ );
1335
+ cacheKey = digestCacheKey(context, resolvedOptions);
1269
1336
 
1270
1337
  const apiKey = process.env.OPENAI_API_KEY;
1271
1338
  if (!apiKey) {
@@ -1277,12 +1344,12 @@ export function streamPeriodDigestEffect(
1277
1344
  const response = yield* tryDigestPromise(() =>
1278
1345
  fetch("https://api.openai.com/v1/responses", {
1279
1346
  method: "POST",
1280
- signal: options.signal,
1347
+ signal: resolvedOptions.signal,
1281
1348
  headers: {
1282
1349
  authorization: `Bearer ${apiKey}`,
1283
1350
  "content-type": "application/json",
1284
1351
  },
1285
- body: JSON.stringify(createOpenAIRequestBody(context, options)),
1352
+ body: JSON.stringify(createOpenAIRequestBody(context, resolvedOptions)),
1286
1353
  }),
1287
1354
  );
1288
1355
  if (!response.ok) {
@@ -1296,7 +1363,12 @@ export function streamPeriodDigestEffect(
1296
1363
  ),
1297
1364
  );
1298
1365
  }
1299
- return yield* readOpenAIStreamEffect(response, context, options, handlers);
1366
+ return yield* readOpenAIStreamEffect(
1367
+ response,
1368
+ context,
1369
+ resolvedOptions,
1370
+ handlers,
1371
+ );
1300
1372
  });
1301
1373
  }
1302
1374
 
@@ -1310,6 +1382,9 @@ export function streamPeriodDigest(
1310
1382
  export const __test__ = {
1311
1383
  PeriodDigestSchema,
1312
1384
  buildPrompt,
1385
+ digestCacheKey,
1386
+ languageFromOptions,
1387
+ normalizeDigestLanguage,
1313
1388
  readOpenAIStreamEffect,
1314
1389
  parseDigestFromHybridText,
1315
1390
  processSseChunk,
@@ -3,11 +3,13 @@ import { Effect } from "effect";
3
3
  import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
4
4
  import { runEffectBackground } from "#/lib/effect-runtime";
5
5
  import {
6
+ jsonResponse,
6
7
  parseBoundedInteger,
7
8
  runRouteEffect,
8
9
  sensitiveRequestErrorResponse,
9
10
  } from "#/lib/http-effect";
10
11
  import {
12
+ normalizeDigestLanguage,
11
13
  streamPeriodDigestEffect,
12
14
  type PeriodDigestOptions,
13
15
  type PeriodDigestStreamEvent,
@@ -28,6 +30,9 @@ function parseOptions(url: URL): PeriodDigestOptions {
28
30
  includeDms: parseBoolean(url.searchParams.get("includeDms")),
29
31
  refresh: parseBoolean(url.searchParams.get("refresh")),
30
32
  model: url.searchParams.get("model") === "gpt-5.5" ? "gpt-5.5" : undefined,
33
+ language: normalizeDigestLanguage(
34
+ url.searchParams.get("language") ?? undefined,
35
+ ),
31
36
  maxTweets: parseBoundedInteger(url.searchParams.get("maxTweets"), {
32
37
  max: 5_000,
33
38
  }),
@@ -60,9 +65,20 @@ export const Route = createFileRoute("/api/period-digest")({
60
65
  const denied = sensitiveRequestErrorResponse(request);
61
66
  if (denied) return denied;
62
67
 
63
- yield* maybeAutoUpdateBackupEffect();
64
68
  const url = new URL(request.url);
65
- const options = parseOptions(url);
69
+ let options: PeriodDigestOptions;
70
+ try {
71
+ options = parseOptions(url);
72
+ } catch (error) {
73
+ return jsonResponse(
74
+ {
75
+ ok: false,
76
+ error: error instanceof Error ? error.message : String(error),
77
+ },
78
+ { status: 400 },
79
+ );
80
+ }
81
+ yield* maybeAutoUpdateBackupEffect();
66
82
  let abortDigest: (() => void) | undefined;
67
83
 
68
84
  return new Response(
@@ -169,7 +169,9 @@ function CapabilityRow({
169
169
  }
170
170
 
171
171
  function DataSourcesRoute() {
172
- const [snapshot, setSnapshot] = useState<LiveDataSourcesResponse | null>(null);
172
+ const [snapshot, setSnapshot] = useState<LiveDataSourcesResponse | null>(
173
+ null,
174
+ );
173
175
  const [error, setError] = useState<string | null>(null);
174
176
  const [loading, setLoading] = useState(true);
175
177
  const sourcesByKind = useMemo(