@chenpengfei/daily-brief 0.1.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +21 -0
  3. package/README.md +28 -0
  4. package/config/sources.example.yaml +20 -0
  5. package/dist/src/adapters/fixture.js +70 -0
  6. package/dist/src/adapters/github-trending.js +183 -0
  7. package/dist/src/adapters/index.js +5 -0
  8. package/dist/src/adapters/rss.js +156 -0
  9. package/dist/src/adapters/types.js +1 -0
  10. package/dist/src/adapters/x.js +115 -0
  11. package/dist/src/agent/daily-brief-agent.js +350 -0
  12. package/dist/src/agent/index.js +10 -0
  13. package/dist/src/agent/model-runtime-config.js +221 -0
  14. package/dist/src/agent/model-stage-runtime.js +63 -0
  15. package/dist/src/agent/signal-narrative.js +247 -0
  16. package/dist/src/agent/signal-selection-ranking.js +276 -0
  17. package/dist/src/agent/source-grounding-audit.js +148 -0
  18. package/dist/src/agent/source-grounding-repair.js +159 -0
  19. package/dist/src/agent/source-item-understanding.js +206 -0
  20. package/dist/src/agent/stage-contracts.js +205 -0
  21. package/dist/src/agent/stage-runner.js +66 -0
  22. package/dist/src/brief/daily-brief.js +234 -0
  23. package/dist/src/brief/index.js +1 -0
  24. package/dist/src/cli.js +531 -0
  25. package/dist/src/collection/collect.js +67 -0
  26. package/dist/src/collection/index.js +1 -0
  27. package/dist/src/config/credential-store.js +169 -0
  28. package/dist/src/config/date-key.js +25 -0
  29. package/dist/src/config/index.js +5 -0
  30. package/dist/src/config/model-config.js +123 -0
  31. package/dist/src/config/paths.js +20 -0
  32. package/dist/src/config/source-registry.js +48 -0
  33. package/dist/src/discord/delivery.js +84 -0
  34. package/dist/src/discord/index.js +1 -0
  35. package/dist/src/domain/index.js +2 -0
  36. package/dist/src/domain/source-item.js +21 -0
  37. package/dist/src/domain/source.js +93 -0
  38. package/dist/src/storage/agent-run-artifact.js +44 -0
  39. package/dist/src/storage/brief-archive.js +17 -0
  40. package/dist/src/storage/index.js +3 -0
  41. package/dist/src/storage/source-item-store.js +63 -0
  42. package/dist/src/workflow/index.js +1 -0
  43. package/dist/src/workflow/status.js +95 -0
  44. package/docs/operations.md +74 -0
  45. package/docs/release-workflow.md +220 -0
  46. package/docs/user-manual.md +146 -0
  47. package/package.json +65 -0
  48. package/templates/daily-brief.md +9 -0
  49. package/templates/discord-notification.md +7 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ All notable changes for Formal Releases are recorded here. GitHub Release notes should be derived from the matching version entry.
4
+
5
+ ## 0.1.0 - 2026-05-31
6
+
7
+ Initial Formal Release candidate for the Daily Brief Agent.
8
+
9
+ ### User-visible Changes
10
+
11
+ - Ships the `daily-brief` Operational CLI as the npm package `@chenpengfei/daily-brief`.
12
+ - Supports first-use setup through `daily-brief setup`.
13
+ - Supports source management, model configuration, delivery configuration, status inspection, and manual workflow runs from the installed CLI.
14
+ - Generates Chinese Daily Brief output from configured Sources through the Agent-driven brief generation workflow.
15
+
16
+ ### Installation and Upgrade Notes
17
+
18
+ - Install with `npm install -g @chenpengfei/daily-brief`.
19
+ - Run `daily-brief setup` after installation to initialize user configuration and generated-data directories.
20
+ - Generated data and user configuration live under the user Daily Brief home by default, not in the repository checkout.
21
+
22
+ ### Known Limitations
23
+
24
+ - The first release does not include a built-in scheduler; use cron, launchd, systemd, GitHub Actions, or another external scheduler.
25
+ - Discord is the initial Delivery Channel.
26
+ - Source discovery remains manual through the Source Registry; the agent does not add Sources autonomously.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 陈鹏飞 chenpengfei
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # Daily Brief
2
+
3
+ Daily Brief is a personal intelligence workflow for generating a recurring brief about Agent architecture, AI Coding, and related ecosystem signals from manually configured Sources.
4
+
5
+ ## Install
6
+
7
+ Daily Brief is distributed as the npm package `@chenpengfei/daily-brief` and installs the `daily-brief` command.
8
+
9
+ ```bash
10
+ npm install -g @chenpengfei/daily-brief
11
+ daily-brief setup
12
+ ```
13
+
14
+ Daily Brief requires Node.js 22 or newer.
15
+
16
+ ## Use
17
+
18
+ ```bash
19
+ daily-brief run-once
20
+ daily-brief status
21
+ daily-brief sources list
22
+ daily-brief model status
23
+ daily-brief delivery status
24
+ ```
25
+
26
+ For installation, setup, configuration, upgrade, and troubleshooting, see `docs/user-manual.md`.
27
+
28
+ For maintainer release gates and publication steps, see `docs/release-workflow.md`.
@@ -0,0 +1,20 @@
1
+ # Example Source Registry for Daily Brief.
2
+ #
3
+ # User-specific Source Registry lives outside the repository, normally at:
4
+ # ~/.daily-brief/sources.yaml
5
+ #
6
+ # Each Source has:
7
+ # - id: stable unique identifier
8
+ # - platform: content platform, such as x, blog, github, or youtube
9
+ # - adapter: logical Fetch Adapter name
10
+ # - target: adapter-specific locator or query
11
+ # - enabled: collection switch
12
+ # - notes: human-readable reason for including the Source
13
+
14
+ sources:
15
+ - id: github-trending-daily
16
+ platform: github
17
+ adapter: github-trending
18
+ target: https://github.com/trending?since=daily
19
+ enabled: true
20
+ notes: Site-wide daily GitHub Trending; Brief generation filters for Agent Architecture and AI Coding signals
@@ -0,0 +1,70 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { createSourceItem } from "../domain/index.js";
3
+ export const fixtureFetchAdapter = {
4
+ name: "fixture",
5
+ async fetch(source, context) {
6
+ const contents = await readFile(source.target, "utf8");
7
+ const fixture = parseFixtureFile(JSON.parse(contents));
8
+ return fixture.items.map((item) => {
9
+ const input = {
10
+ id: `${source.id}:${item.id}`,
11
+ sourceId: source.id,
12
+ platform: source.platform,
13
+ url: item.url,
14
+ title: item.title,
15
+ fetchedAt: context.fetchedAt.toISOString(),
16
+ analyzableText: item.analyzableText,
17
+ ...(item.author ? { author: item.author } : {}),
18
+ ...(item.publishedAt ? { publishedAt: item.publishedAt } : {})
19
+ };
20
+ return createSourceItem(input);
21
+ });
22
+ }
23
+ };
24
+ function parseFixtureFile(value) {
25
+ if (!isRecord(value) || !Array.isArray(value.items)) {
26
+ throw new Error("Fixture adapter target must be a JSON object with an items list");
27
+ }
28
+ return {
29
+ items: value.items.map(parseFixtureSourceItem)
30
+ };
31
+ }
32
+ function parseFixtureSourceItem(value) {
33
+ if (!isRecord(value)) {
34
+ throw new Error("Fixture Source Item must be an object");
35
+ }
36
+ const id = readString(value, "id");
37
+ const url = readString(value, "url");
38
+ const title = readString(value, "title");
39
+ const analyzableText = readString(value, "analyzableText");
40
+ const author = readOptionalString(value, "author");
41
+ const publishedAt = readOptionalString(value, "publishedAt");
42
+ return {
43
+ id,
44
+ url,
45
+ title,
46
+ ...(author ? { author } : {}),
47
+ ...(publishedAt ? { publishedAt } : {}),
48
+ analyzableText
49
+ };
50
+ }
51
+ function readString(source, key) {
52
+ const value = source[key];
53
+ if (typeof value !== "string" || value.trim().length === 0) {
54
+ throw new Error(`Fixture Source Item ${key} must be a non-empty string`);
55
+ }
56
+ return value.trim();
57
+ }
58
+ function readOptionalString(source, key) {
59
+ const value = source[key];
60
+ if (value === undefined) {
61
+ return undefined;
62
+ }
63
+ if (typeof value !== "string" || value.trim().length === 0) {
64
+ throw new Error(`Fixture Source Item ${key} must be a non-empty string when present`);
65
+ }
66
+ return value.trim();
67
+ }
68
+ function isRecord(value) {
69
+ return typeof value === "object" && value !== null && !Array.isArray(value);
70
+ }
@@ -0,0 +1,183 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { createSourceItem } from "../domain/index.js";
4
+ export function createGitHubTrendingFetchAdapter(options = {}) {
5
+ return {
6
+ name: "github-trending",
7
+ async fetch(source, context) {
8
+ const candidates = await readGitHubCandidates(source.target, options.fetchImpl);
9
+ const observedForDate = (context.collectionDate ?? context.fetchedAt).toISOString().slice(0, 10);
10
+ const trendingRange = readTrendingRange(source.target);
11
+ return candidates.map((candidate) => {
12
+ const input = {
13
+ id: `${source.id}:${stableRepoId(candidate, observedForDate)}`,
14
+ sourceId: source.id,
15
+ platform: source.platform,
16
+ url: candidate.url,
17
+ title: candidate.fullName,
18
+ fetchedAt: context.fetchedAt.toISOString(),
19
+ analyzableText: buildAnalyzableText(candidate),
20
+ metadata: {
21
+ repoName: candidate.fullName,
22
+ ...(candidate.description ? { description: candidate.description } : {}),
23
+ stars: candidate.stars,
24
+ forks: candidate.forks,
25
+ watchers: candidate.watchers,
26
+ starsToday: candidate.starsToday,
27
+ previousStars: candidate.previousStars,
28
+ observedForDate,
29
+ ...(trendingRange ? { trendingRange } : {})
30
+ },
31
+ ...(candidate.owner ? { author: candidate.owner } : {}),
32
+ ...(candidate.pushedAt ? { publishedAt: candidate.pushedAt } : {})
33
+ };
34
+ return createSourceItem(input);
35
+ });
36
+ }
37
+ };
38
+ }
39
+ export const githubTrendingFetchAdapter = createGitHubTrendingFetchAdapter();
40
+ async function readGitHubCandidates(target, fetchImpl = fetch) {
41
+ if (target.startsWith("http://") || target.startsWith("https://")) {
42
+ const response = await fetchImpl(target);
43
+ if (!response.ok) {
44
+ throw new Error(`GitHub target returned ${response.status}`);
45
+ }
46
+ const body = await response.text();
47
+ return target.includes("github.com/trending") ? parseTrendingHtml(body) : parseGitHubJson(body);
48
+ }
49
+ return parseGitHubJson(await readFile(target, "utf8"));
50
+ }
51
+ function parseGitHubJson(body) {
52
+ const parsed = JSON.parse(body);
53
+ const items = isRecord(parsed) && Array.isArray(parsed.items) ? parsed.items : Array.isArray(parsed) ? parsed : undefined;
54
+ if (!items) {
55
+ throw new Error("GitHub fixture/API response must contain an items list");
56
+ }
57
+ return items.map(parseGitHubRepoCandidate);
58
+ }
59
+ function parseGitHubRepoCandidate(value) {
60
+ if (!isRecord(value)) {
61
+ throw new Error("GitHub repository candidate must be an object");
62
+ }
63
+ const fullName = readString(value, "full_name", "fullName");
64
+ const url = readString(value, "html_url", "url");
65
+ const owner = isRecord(value.owner) ? optionalString(value.owner.login) : optionalString(value.owner);
66
+ const description = optionalString(value.description);
67
+ const stars = optionalNumber(value.stargazers_count ?? value.stars);
68
+ const forks = optionalNumber(value.forks_count ?? value.forks);
69
+ const watchers = optionalNumber(value.watchers_count ?? value.watchers);
70
+ const starsToday = optionalNumber(value.stars_today ?? value.starsToday);
71
+ const previousStars = optionalNumber(value.previous_stargazers_count ?? value.previousStars);
72
+ const pushedAt = optionalString(value.pushed_at ?? value.pushedAt);
73
+ return {
74
+ fullName,
75
+ url,
76
+ ...(description ? { description } : {}),
77
+ ...(owner ? { owner } : {}),
78
+ ...(stars !== undefined ? { stars } : {}),
79
+ ...(forks !== undefined ? { forks } : {}),
80
+ ...(watchers !== undefined ? { watchers } : {}),
81
+ ...(starsToday !== undefined ? { starsToday } : {}),
82
+ ...(previousStars !== undefined ? { previousStars } : {}),
83
+ ...(pushedAt ? { pushedAt } : {})
84
+ };
85
+ }
86
+ function parseTrendingHtml(body) {
87
+ const articlePattern = /<article[\s\S]*?<\/article>/g;
88
+ const articles = body.match(articlePattern) ?? [];
89
+ return articles.flatMap((article) => {
90
+ const repoMatch = article.match(/<h2[\s\S]*?href="\/([^/"]+\/[^/"]+)"/);
91
+ if (!repoMatch?.[1]) {
92
+ return [];
93
+ }
94
+ const fullName = repoMatch[1].replace(/\s+/g, "");
95
+ const description = stripMarkup(article.match(/<p[^>]*>([\s\S]*?)<\/p>/)?.[1] ?? "");
96
+ const stars = numberFromText(article.match(/aria-label="([\d,]+) stars"/)?.[1]);
97
+ const forks = numberFromText(article.match(/aria-label="([\d,]+) forks"/)?.[1]);
98
+ const starsToday = numberFromText(article.match(/([\d,]+)\s+stars today/)?.[1]);
99
+ return [
100
+ {
101
+ fullName,
102
+ url: `https://github.com/${fullName}`,
103
+ ...(fullName.split("/")[0] ? { owner: fullName.split("/")[0] } : {}),
104
+ ...(description ? { description } : {}),
105
+ ...(stars !== undefined ? { stars } : {}),
106
+ ...(forks !== undefined ? { forks } : {}),
107
+ ...(stars !== undefined ? { watchers: stars } : {}),
108
+ ...(starsToday !== undefined ? { starsToday } : {})
109
+ }
110
+ ];
111
+ });
112
+ }
113
+ function buildAnalyzableText(candidate) {
114
+ const metrics = [
115
+ candidate.stars !== undefined ? `${candidate.stars} stars` : undefined,
116
+ candidate.forks !== undefined ? `${candidate.forks} forks` : undefined,
117
+ candidate.watchers !== undefined ? `${candidate.watchers} watchers` : undefined
118
+ ].filter(Boolean);
119
+ const momentum = candidate.starsToday
120
+ ? `Momentum: +${candidate.starsToday} stars today.`
121
+ : candidate.previousStars !== undefined && candidate.stars !== undefined
122
+ ? `Momentum: ${candidate.stars - candidate.previousStars >= 0 ? "+" : ""}${candidate.stars - candidate.previousStars} stars since previous collection.`
123
+ : "Momentum: no recent star delta available.";
124
+ return [
125
+ candidate.description ?? "GitHub repository candidate.",
126
+ metrics.length > 0 ? `Metrics: ${metrics.join(", ")}.` : undefined,
127
+ momentum,
128
+ "Ordinary commits are not treated as Source Items by this adapter."
129
+ ]
130
+ .filter(Boolean)
131
+ .join(" ");
132
+ }
133
+ function stableRepoId(candidate, observedForDate) {
134
+ return createHash("sha256").update(`${candidate.url}|${observedForDate}`).digest("hex").slice(0, 16);
135
+ }
136
+ function readTrendingRange(target) {
137
+ try {
138
+ const url = new URL(target);
139
+ if (!url.hostname.endsWith("github.com") || url.pathname !== "/trending") {
140
+ return undefined;
141
+ }
142
+ return url.searchParams.get("since") ?? "daily";
143
+ }
144
+ catch {
145
+ return undefined;
146
+ }
147
+ }
148
+ function readString(source, primary, fallback) {
149
+ const value = optionalString(source[primary] ?? source[fallback]);
150
+ if (!value) {
151
+ throw new Error(`GitHub repository candidate requires ${primary}`);
152
+ }
153
+ return value;
154
+ }
155
+ function optionalString(value) {
156
+ if (typeof value !== "string") {
157
+ return undefined;
158
+ }
159
+ const trimmed = value.trim();
160
+ return trimmed.length > 0 ? trimmed : undefined;
161
+ }
162
+ function optionalNumber(value) {
163
+ if (typeof value === "number" && Number.isFinite(value)) {
164
+ return value;
165
+ }
166
+ if (typeof value === "string") {
167
+ return numberFromText(value);
168
+ }
169
+ return undefined;
170
+ }
171
+ function numberFromText(value) {
172
+ if (!value) {
173
+ return undefined;
174
+ }
175
+ const parsed = Number(value.replaceAll(",", ""));
176
+ return Number.isFinite(parsed) ? parsed : undefined;
177
+ }
178
+ function stripMarkup(value) {
179
+ return value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
180
+ }
181
+ function isRecord(value) {
182
+ return typeof value === "object" && value !== null && !Array.isArray(value);
183
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./fixture.js";
2
+ export * from "./github-trending.js";
3
+ export * from "./rss.js";
4
+ export * from "./types.js";
5
+ export * from "./x.js";
@@ -0,0 +1,156 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { XMLParser } from "fast-xml-parser";
4
+ import { createSourceItem } from "../domain/index.js";
5
+ export function createRssFetchAdapter(options = {}) {
6
+ return {
7
+ name: "rss",
8
+ async fetch(source, context) {
9
+ const feed = await readFeedTarget(source.target, options.fetchImpl);
10
+ const entries = parseFeedEntries(feed);
11
+ return entries.map((entry) => createSourceItem({
12
+ id: `${source.id}:${stableEntryId(entry)}`,
13
+ sourceId: source.id,
14
+ platform: source.platform,
15
+ url: entry.url,
16
+ title: entry.title,
17
+ fetchedAt: context.fetchedAt.toISOString(),
18
+ analyzableText: entry.analyzableText,
19
+ ...(entry.author ? { author: entry.author } : {}),
20
+ ...(entry.publishedAt ? { publishedAt: entry.publishedAt } : {})
21
+ }));
22
+ }
23
+ };
24
+ }
25
+ export const rssFetchAdapter = createRssFetchAdapter();
26
+ async function readFeedTarget(target, fetchImpl = fetch) {
27
+ if (target.startsWith("http://") || target.startsWith("https://")) {
28
+ const response = await fetchImpl(target);
29
+ if (!response.ok) {
30
+ throw new Error(`RSS target returned ${response.status}`);
31
+ }
32
+ return response.text();
33
+ }
34
+ return readFile(target, "utf8");
35
+ }
36
+ function parseFeedEntries(feed) {
37
+ const parser = new XMLParser({
38
+ ignoreAttributes: false,
39
+ attributeNamePrefix: "@_",
40
+ textNodeName: "#text"
41
+ });
42
+ const parsed = parser.parse(feed);
43
+ if (!isRecord(parsed)) {
44
+ throw new Error("RSS/Atom feed did not parse into an object");
45
+ }
46
+ if (isRecord(parsed.rss)) {
47
+ return parseRssEntries(parsed.rss);
48
+ }
49
+ if (isRecord(parsed.feed)) {
50
+ return parseAtomEntries(parsed.feed);
51
+ }
52
+ throw new Error("Unsupported feed format: expected RSS or Atom");
53
+ }
54
+ function parseRssEntries(rss) {
55
+ const channel = rss.channel;
56
+ if (!isRecord(channel)) {
57
+ throw new Error("RSS feed missing channel");
58
+ }
59
+ return toArray(channel.item).map((item) => {
60
+ if (!isRecord(item)) {
61
+ throw new Error("RSS item must be an object");
62
+ }
63
+ const title = textValue(item.title, "RSS item title");
64
+ const url = textValue(item.link, "RSS item link");
65
+ const analyzableText = textValue(item.description ?? item["content:encoded"] ?? title, "RSS item description");
66
+ const author = optionalTextValue(item.author ?? item["dc:creator"]);
67
+ const publishedAt = normalizeDate(optionalTextValue(item.pubDate));
68
+ return {
69
+ title,
70
+ url,
71
+ analyzableText: stripMarkup(analyzableText),
72
+ ...(author ? { author } : {}),
73
+ ...(publishedAt ? { publishedAt } : {})
74
+ };
75
+ });
76
+ }
77
+ function parseAtomEntries(feed) {
78
+ return toArray(feed.entry).map((entry) => {
79
+ if (!isRecord(entry)) {
80
+ throw new Error("Atom entry must be an object");
81
+ }
82
+ const title = textValue(entry.title, "Atom entry title");
83
+ const url = atomLink(entry.link);
84
+ const analyzableText = textValue(entry.summary ?? entry.content ?? title, "Atom entry summary");
85
+ const author = atomAuthor(entry.author);
86
+ const publishedAt = normalizeDate(optionalTextValue(entry.published ?? entry.updated));
87
+ return {
88
+ title,
89
+ url,
90
+ analyzableText: stripMarkup(analyzableText),
91
+ ...(author ? { author } : {}),
92
+ ...(publishedAt ? { publishedAt } : {})
93
+ };
94
+ });
95
+ }
96
+ function atomLink(value) {
97
+ const links = toArray(value);
98
+ const alternate = links.find((link) => isRecord(link) && (link["@_rel"] === "alternate" || link["@_rel"] === undefined));
99
+ const selected = alternate ?? links[0];
100
+ if (isRecord(selected)) {
101
+ return textValue(selected["@_href"] ?? selected.href, "Atom entry link");
102
+ }
103
+ return textValue(selected, "Atom entry link");
104
+ }
105
+ function atomAuthor(value) {
106
+ if (isRecord(value)) {
107
+ return optionalTextValue(value.name);
108
+ }
109
+ return optionalTextValue(value);
110
+ }
111
+ function stableEntryId(entry) {
112
+ return createHash("sha256")
113
+ .update(JSON.stringify({ url: entry.url, title: entry.title, publishedAt: entry.publishedAt }))
114
+ .digest("hex")
115
+ .slice(0, 16);
116
+ }
117
+ function normalizeDate(value) {
118
+ if (!value) {
119
+ return undefined;
120
+ }
121
+ const date = new Date(value);
122
+ if (Number.isNaN(date.getTime())) {
123
+ return value;
124
+ }
125
+ return date.toISOString();
126
+ }
127
+ function stripMarkup(value) {
128
+ return value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
129
+ }
130
+ function textValue(value, label) {
131
+ const text = optionalTextValue(value);
132
+ if (!text) {
133
+ throw new Error(`${label} must be present`);
134
+ }
135
+ return text;
136
+ }
137
+ function optionalTextValue(value) {
138
+ if (typeof value === "string" || typeof value === "number") {
139
+ const text = String(value).trim();
140
+ return text.length > 0 ? text : undefined;
141
+ }
142
+ if (isRecord(value) && typeof value["#text"] === "string") {
143
+ const text = value["#text"].trim();
144
+ return text.length > 0 ? text : undefined;
145
+ }
146
+ return undefined;
147
+ }
148
+ function toArray(value) {
149
+ if (value === undefined || value === null) {
150
+ return [];
151
+ }
152
+ return Array.isArray(value) ? value : [value];
153
+ }
154
+ function isRecord(value) {
155
+ return typeof value === "object" && value !== null && !Array.isArray(value);
156
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,115 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { createSourceItem } from "../domain/index.js";
3
+ export function createXFetchAdapter(options = {}) {
4
+ return {
5
+ name: "x",
6
+ async fetch(source, context) {
7
+ const posts = await readXPosts(source.target, options.fetchImpl);
8
+ return posts.filter(shouldKeepPost).map((post) => createSourceItem({
9
+ id: `${source.id}:${post.id}`,
10
+ sourceId: source.id,
11
+ platform: source.platform,
12
+ url: post.url,
13
+ title: `${post.author ?? source.id}: ${firstLine(post.addedText ?? post.text)}`,
14
+ fetchedAt: context.fetchedAt.toISOString(),
15
+ analyzableText: post.addedText ? `${post.addedText}\n\nQuoted context: ${post.text}` : post.text,
16
+ metadata: {
17
+ postType: post.type ?? "original",
18
+ sourceTarget: source.target
19
+ },
20
+ ...(post.author ? { author: post.author } : {}),
21
+ ...(post.createdAt ? { publishedAt: post.createdAt } : {})
22
+ }));
23
+ }
24
+ };
25
+ }
26
+ export const xFetchAdapter = createXFetchAdapter();
27
+ async function readXPosts(target, fetchImpl = fetch) {
28
+ const body = target.startsWith("http://") || target.startsWith("https://")
29
+ ? await readRemoteTarget(target, fetchImpl)
30
+ : await readFile(target, "utf8");
31
+ const parsed = JSON.parse(body);
32
+ const posts = isRecord(parsed) && Array.isArray(parsed.posts) ? parsed.posts : Array.isArray(parsed) ? parsed : undefined;
33
+ if (!posts) {
34
+ throw new Error("X adapter fixture/response must contain a posts list");
35
+ }
36
+ return posts.map(parseXPost);
37
+ }
38
+ async function readRemoteTarget(target, fetchImpl) {
39
+ const response = await fetchImpl(target);
40
+ if (!response.ok) {
41
+ throw new Error(`X target returned ${response.status}`);
42
+ }
43
+ return response.text();
44
+ }
45
+ function parseXPost(value) {
46
+ if (!isRecord(value)) {
47
+ throw new Error("X post must be an object");
48
+ }
49
+ const id = readString(value, "id");
50
+ const url = readString(value, "url");
51
+ const text = readString(value, "text");
52
+ const type = optionalPostType(value.type);
53
+ const author = optionalString(value.author);
54
+ const createdAt = optionalString(value.createdAt ?? value.created_at);
55
+ const addedText = optionalString(value.addedText ?? value.added_text);
56
+ return {
57
+ id,
58
+ url,
59
+ text,
60
+ ...(type ? { type } : {}),
61
+ ...(author ? { author } : {}),
62
+ ...(createdAt ? { createdAt } : {}),
63
+ ...(addedText ? { addedText } : {})
64
+ };
65
+ }
66
+ function shouldKeepPost(post) {
67
+ const type = post.type ?? "original";
68
+ if (type === "repost") {
69
+ return false;
70
+ }
71
+ if (type === "quote" || type === "reply") {
72
+ return isFocusRelevant(`${post.addedText ?? ""} ${post.text}`);
73
+ }
74
+ return isFocusRelevant(post.text);
75
+ }
76
+ function isFocusRelevant(text) {
77
+ const normalized = text.toLowerCase();
78
+ const terms = [
79
+ "agent architecture",
80
+ "agent runtime",
81
+ "coding agent",
82
+ "ai coding",
83
+ "tool execution",
84
+ "eval",
85
+ "memory",
86
+ "mcp"
87
+ ];
88
+ return terms.some((term) => normalized.includes(term));
89
+ }
90
+ function firstLine(text) {
91
+ return text.replace(/\s+/g, " ").trim().slice(0, 96);
92
+ }
93
+ function readString(source, key) {
94
+ const value = optionalString(source[key]);
95
+ if (!value) {
96
+ throw new Error(`X post ${key} must be a non-empty string`);
97
+ }
98
+ return value;
99
+ }
100
+ function optionalString(value) {
101
+ if (typeof value !== "string") {
102
+ return undefined;
103
+ }
104
+ const trimmed = value.trim();
105
+ return trimmed.length > 0 ? trimmed : undefined;
106
+ }
107
+ function optionalPostType(value) {
108
+ if (value === "original" || value === "repost" || value === "quote" || value === "reply") {
109
+ return value;
110
+ }
111
+ return undefined;
112
+ }
113
+ function isRecord(value) {
114
+ return typeof value === "object" && value !== null && !Array.isArray(value);
115
+ }