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 +16 -0
- package/README.md +7 -2
- package/package.json +24 -30
- package/scripts/build-docs-site.mjs +39 -12
- package/src/cli.ts +19 -0
- package/src/lib/link-insights.ts +2 -0
- package/src/lib/period-digest.ts +96 -21
- package/src/routes/api/period-digest.tsx +18 -2
- package/src/routes/data-sources.tsx +3 -1
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
|
|
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.
|
|
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.
|
|
60
|
-
"@tanstack/react-router-devtools": "1.
|
|
61
|
-
"@tanstack/react-router-ssr-query": "1.
|
|
62
|
-
"@tanstack/react-start": "1.
|
|
63
|
-
"@tanstack/router-plugin": "^1.
|
|
64
|
-
"@vitejs/plugin-react": "^6.0.
|
|
65
|
-
"commander": "^
|
|
66
|
-
"effect": "^3.21.
|
|
67
|
-
"kysely": "^0.29.
|
|
68
|
-
"lucide-react": "^1.
|
|
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.
|
|
71
|
-
"react-dom": "^19.2.
|
|
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.
|
|
76
|
-
"vite": "^8.0.
|
|
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.
|
|
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.
|
|
87
|
-
"@types/react": "^19.2.
|
|
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.
|
|
91
|
-
"@vitest/coverage-v8": "^4.1.
|
|
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.
|
|
94
|
-
"oxlint": "^1.
|
|
93
|
+
"oxfmt": "^0.54.0",
|
|
94
|
+
"oxlint": "^1.69.0",
|
|
95
95
|
"typescript": "^6.0.3",
|
|
96
|
-
"vitest": "^4.1.
|
|
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
|
-
[
|
|
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
|
-
|
|
453
|
-
.
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
.
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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(/<br>/g, "<br>");
|
|
464
|
-
return out
|
|
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")
|
package/src/lib/link-insights.ts
CHANGED
|
@@ -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;
|
package/src/lib/period-digest.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
]
|
|
876
|
+
];
|
|
877
|
+
const lang = languageFromOptions(options);
|
|
878
|
+
if (lang) parts.push(`lang:${lang}`);
|
|
879
|
+
return parts.join(":");
|
|
844
880
|
}
|
|
845
881
|
|
|
846
|
-
function buildPrompt(
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
1282
|
+
resolvedOptions,
|
|
1218
1283
|
{ threads: false },
|
|
1219
1284
|
handlers,
|
|
1220
1285
|
).pipe(Effect.catchAll(() => Effect.void));
|
|
1221
1286
|
let context = yield* tryDigestSync(() =>
|
|
1222
|
-
collectPeriodDigestContext(
|
|
1287
|
+
collectPeriodDigestContext(resolvedOptions),
|
|
1223
1288
|
);
|
|
1224
|
-
let cacheKey = digestCacheKey(context,
|
|
1225
|
-
const cached =
|
|
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
|
-
|
|
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(() =>
|
|
1268
|
-
|
|
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:
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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>(
|
|
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(
|