shipwatch 0.1.0__py3-none-any.whl

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.
shipwatch/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """shipwatch: changelogs in, a cited weekly briefing out."""
2
+
3
+ from shipwatch._version import __version__
4
+
5
+ __all__ = ["__version__"]
shipwatch/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from shipwatch.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
shipwatch/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Package version. Single source of truth for pyproject and the CLI."""
2
+
3
+ __version__ = "0.1.0"
shipwatch/cli.py ADDED
@@ -0,0 +1,218 @@
1
+ """Command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from shipwatch._version import __version__
11
+ from shipwatch.config import (
12
+ AppConfig,
13
+ bundled_sources_path,
14
+ load_config,
15
+ load_yaml,
16
+ parse_sources,
17
+ write_init_files,
18
+ )
19
+ from shipwatch.fetch.client import PoliteClient, check_url
20
+ from shipwatch.models import SourceKind
21
+ from shipwatch.output.render import coverage_line, write_outputs
22
+ from shipwatch.pipeline.run import run_brief, run_fetch, run_pipeline
23
+ from shipwatch.storage import Store
24
+
25
+
26
+ def build_parser() -> argparse.ArgumentParser:
27
+ parser = argparse.ArgumentParser(
28
+ prog="shipwatch",
29
+ description="Know what AI companies actually shipped this week.",
30
+ )
31
+ parser.add_argument("--version", action="version", version=f"shipwatch {__version__}")
32
+ parser.add_argument("--config", type=Path, help="Path to shipwatch.yaml")
33
+ parser.add_argument("--data-dir", type=Path, help="Override SQLite data directory")
34
+ parser.add_argument(
35
+ "--no-llm",
36
+ action="store_true",
37
+ help="Heuristic characterization, scoring, and extractive brief",
38
+ )
39
+ sub = parser.add_subparsers(dest="command", required=True)
40
+
41
+ init = sub.add_parser("init", help="Write sample config and source pack")
42
+ init.add_argument("--force", action="store_true")
43
+ init.add_argument("--dir", type=Path, default=Path("."))
44
+
45
+ fetch = sub.add_parser("fetch", help="Fetch sources into SQLite")
46
+ fetch.add_argument("--source", help="Fetch a single source id")
47
+
48
+ run = sub.add_parser("run", help="Fetch + score + brief")
49
+ _brief_flags(run)
50
+
51
+ brief = sub.add_parser("brief", help="Brief from items already in SQLite")
52
+ _brief_flags(brief)
53
+
54
+ sources = sub.add_parser("sources", help="Inspect the source pack")
55
+ src_sub = sources.add_subparsers(dest="sources_command", required=True)
56
+ check = src_sub.add_parser("check", help="Validate sources (add --live to hit the network)")
57
+ check.add_argument("--live", action="store_true")
58
+
59
+ sub.add_parser("stats", help="Show archive counts")
60
+ return parser
61
+
62
+
63
+ def _brief_flags(parser: argparse.ArgumentParser) -> None:
64
+ parser.add_argument("--since", help="Window such as 7d, 2w, or an ISO timestamp")
65
+ parser.add_argument("--top", type=int, dest="top_n", help="How many stories to keep")
66
+ parser.add_argument("--out", type=Path, help="Output directory")
67
+ parser.add_argument("--html", action="store_true", help="Also write a minimal index.html")
68
+
69
+
70
+ def main(argv: list[str] | None = None) -> int:
71
+ parser = build_parser()
72
+ args = parser.parse_args(argv)
73
+ try:
74
+ return _dispatch(args)
75
+ except Exception as exc:
76
+ print(f"error: {exc}", file=sys.stderr)
77
+ return 1
78
+
79
+
80
+ def _dispatch(args: argparse.Namespace) -> int:
81
+ if args.command == "init":
82
+ written = write_init_files(args.dir, force=args.force)
83
+ for path in written:
84
+ print(f"wrote {path}")
85
+ print("Edit sources.yaml, then run: shipwatch fetch && shipwatch run --no-llm")
86
+ return 0
87
+
88
+ cfg = _config(args)
89
+ if args.command == "sources":
90
+ return _sources_check(cfg, live=args.live)
91
+
92
+ store = Store(cfg.db_path)
93
+ try:
94
+ if args.command == "fetch":
95
+ stats = run_fetch(cfg, store, source_id=args.source)
96
+ _print_fetch(stats)
97
+ return 0
98
+ if args.command == "run":
99
+ _apply_brief_overrides(cfg, args)
100
+ briefing, items, stats = run_pipeline(
101
+ cfg,
102
+ store,
103
+ since=args.since,
104
+ top_n=args.top_n,
105
+ no_llm=args.no_llm,
106
+ html=args.html or None,
107
+ )
108
+ _print_fetch(stats)
109
+ _print_brief(briefing, cfg)
110
+ return 0
111
+ if args.command == "brief":
112
+ _apply_brief_overrides(cfg, args)
113
+ briefing, items, _stats = run_brief(
114
+ cfg,
115
+ store,
116
+ since=args.since,
117
+ top_n=args.top_n,
118
+ no_llm=args.no_llm,
119
+ html=args.html or None,
120
+ )
121
+ write_outputs(
122
+ briefing,
123
+ items,
124
+ cfg.brief.output_dir,
125
+ html=args.html or cfg.brief.html,
126
+ archive=store.list_briefings(limit=20),
127
+ )
128
+ _print_brief(briefing, cfg)
129
+ return 0
130
+ if args.command == "stats":
131
+ print(json.dumps(store.stats(), indent=2))
132
+ return 0
133
+ finally:
134
+ store.close()
135
+ return 1
136
+
137
+
138
+ def _config(args: argparse.Namespace) -> AppConfig:
139
+ path = args.config
140
+ if path is None:
141
+ candidate = Path("shipwatch.yaml")
142
+ path = candidate if candidate.exists() else None
143
+ cfg = load_config(path)
144
+ if getattr(args, "data_dir", None):
145
+ cfg.data_dir = args.data_dir
146
+ if getattr(args, "no_llm", False):
147
+ pass
148
+ return cfg
149
+
150
+
151
+ def _apply_brief_overrides(cfg: AppConfig, args: argparse.Namespace) -> None:
152
+ if getattr(args, "out", None):
153
+ cfg.brief.output_dir = args.out
154
+ if getattr(args, "html", False):
155
+ cfg.brief.html = True
156
+ if getattr(args, "since", None):
157
+ cfg.brief.since = args.since
158
+ if getattr(args, "top_n", None):
159
+ cfg.brief.top_n = args.top_n
160
+
161
+
162
+ def _print_fetch(stats) -> None:
163
+ print(
164
+ f"fetched {stats.fetched} sources · {stats.items_considered} items ({stats.items_new} new)"
165
+ )
166
+ for outcome in stats.outcomes:
167
+ extra = f" HTTP {outcome.http_status}" if outcome.http_status else ""
168
+ detail = f" — {outcome.detail}" if outcome.detail else ""
169
+ print(f" {outcome.source_id}: {outcome.status}{extra}{detail}")
170
+
171
+
172
+ def _print_brief(briefing, cfg: AppConfig) -> None:
173
+ print(f"{briefing.title}")
174
+ print(coverage_line(briefing.coverage))
175
+ print(f"wrote {cfg.brief.output_dir / 'briefing.md'}")
176
+ print(f"wrote {cfg.brief.output_dir / 'briefing.json'}")
177
+ print(f"wrote {cfg.brief.output_dir / 'briefings.xml'}")
178
+ if cfg.brief.html:
179
+ print(f"wrote {cfg.brief.output_dir / 'index.html'}")
180
+
181
+
182
+ def _sources_check(cfg: AppConfig, *, live: bool) -> int:
183
+ sources = cfg.sources or parse_sources(load_yaml(bundled_sources_path()))
184
+ errors = 0
185
+ for source in sources:
186
+ problems: list[str] = []
187
+ if source.kind is SourceKind.GITHUB and not source.repo:
188
+ problems.append("missing repo")
189
+ if source.kind is not SourceKind.GITHUB and not source.url:
190
+ problems.append("missing url")
191
+ if source.url and not source.url.startswith(("http://", "https://")):
192
+ problems.append("url must be http(s)")
193
+ status = "ok" if not problems else "invalid"
194
+ if problems:
195
+ errors += 1
196
+ extra = f" ({'; '.join(problems)})" if problems else ""
197
+ target = source.repo or source.url or ""
198
+ print(f"{source.id:24} {source.kind.value:8} {status:8} {target}{extra}")
199
+ print(f"{len(sources)} sources · {len(sources) - errors} valid · {errors} errors")
200
+ if not live:
201
+ return 1 if errors else 0
202
+
203
+ client = PoliteClient(cfg.fetch, store=None)
204
+ failed = 0
205
+ try:
206
+ for source in sources:
207
+ if not source.enabled:
208
+ print(f"{source.id:24} skipped (disabled)")
209
+ continue
210
+ result = check_url(client, source.target())
211
+ mark = "ok" if result["ok"] else "FAIL"
212
+ if not result["ok"]:
213
+ failed += 1
214
+ print(f"{source.id:24} {mark:8} {result.get('status')} {result['detail']}")
215
+ finally:
216
+ client.close()
217
+ print(f"live check: {len(sources) - failed} reachable · {failed} failed")
218
+ return 1 if failed or errors else 0
shipwatch/config.py ADDED
@@ -0,0 +1,277 @@
1
+ """YAML config, defaults, and the bundled source pack."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from importlib import resources
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+
13
+ from shipwatch._version import __version__
14
+ from shipwatch.models import SourceKind, SourceSpec
15
+
16
+ DEFAULT_USER_AGENT = f"shipwatch/{__version__} (+https://github.com/andymccutcheon/shipwatch)"
17
+ DEFAULT_FREEWHIRR_BASE = "http://127.0.0.1:8787/v1"
18
+ DEFAULT_FREEWHIRR_MODEL = "freewhirr"
19
+
20
+
21
+ @dataclass
22
+ class LLMSettings:
23
+ base_url: str = DEFAULT_FREEWHIRR_BASE
24
+ model: str = DEFAULT_FREEWHIRR_MODEL
25
+ api_key_env: str = "SHIPWATCH_API_KEY"
26
+ timeout_seconds: float = 60.0
27
+ retries: int = 2
28
+ temperature: float = 0.2
29
+
30
+ def api_key(self) -> str:
31
+ for name in (self.api_key_env, "OPENROUTER_API_KEY", "OPENAI_API_KEY"):
32
+ value = os.environ.get(name, "").strip()
33
+ if value:
34
+ return value
35
+ return "not-used"
36
+
37
+
38
+ @dataclass
39
+ class ScoringSettings:
40
+ novelty: float = 0.25
41
+ impact: float = 0.30
42
+ breadth: float = 0.20
43
+ recency: float = 0.25
44
+ pricing_boost: float = 0.15
45
+ deprecation_boost: float = 0.15
46
+ security_boost: float = 0.10
47
+ recency_half_life_days: float = 7.0
48
+ near_dup_threshold: float = 0.62
49
+ cluster_threshold: float = 0.28
50
+ non_shipment_penalty: float = 0.40
51
+
52
+
53
+ @dataclass
54
+ class BriefSettings:
55
+ top_n: int = 12
56
+ since: str = "7d"
57
+ output_dir: Path = Path("out")
58
+ html: bool = False
59
+
60
+
61
+ @dataclass
62
+ class FetchSettings:
63
+ user_agent: str = DEFAULT_USER_AGENT
64
+ timeout_seconds: float = 30.0
65
+ respect_robots: bool = True
66
+ min_interval_seconds: float = 1.0
67
+ max_items_per_source: int = 40
68
+ github_token_env: str = "GITHUB_TOKEN"
69
+
70
+
71
+ @dataclass
72
+ class AppConfig:
73
+ config_path: Path | None = None
74
+ data_dir: Path = Path(".shipwatch")
75
+ sources_file: Path | None = None
76
+ sources: list[SourceSpec] = field(default_factory=list)
77
+ llm: LLMSettings = field(default_factory=LLMSettings)
78
+ scoring: ScoringSettings = field(default_factory=ScoringSettings)
79
+ brief: BriefSettings = field(default_factory=BriefSettings)
80
+ fetch: FetchSettings = field(default_factory=FetchSettings)
81
+
82
+ @property
83
+ def db_path(self) -> Path:
84
+ return self.data_dir / "shipwatch.db"
85
+
86
+ def enabled_sources(self) -> list[SourceSpec]:
87
+ return [s for s in self.sources if s.enabled]
88
+
89
+
90
+ def bundled_sources_path() -> Path:
91
+ ref = resources.files("shipwatch").joinpath("data/sources.yaml")
92
+ return Path(str(ref))
93
+
94
+
95
+ def default_config_text() -> str:
96
+ return f"""# shipwatch.yaml
97
+ # Know what AI companies actually shipped this week.
98
+
99
+ data_dir: .shipwatch
100
+ sources_file: sources.yaml
101
+
102
+ llm:
103
+ # Default: Andy's free-first OpenRouter router (https://github.com/andymccutcheon/freewhirr)
104
+ # pip install freewhirr && freewhirr serve --port 8787
105
+ base_url: {DEFAULT_FREEWHIRR_BASE}
106
+ model: {DEFAULT_FREEWHIRR_MODEL}
107
+ api_key_env: SHIPWATCH_API_KEY
108
+ # OpenRouter (direct):
109
+ # base_url: https://openrouter.ai/api/v1
110
+ # model: meta-llama/llama-3.3-70b-instruct:free
111
+ # api_key_env: OPENROUTER_API_KEY
112
+ # OpenAI:
113
+ # base_url: https://api.openai.com/v1
114
+ # model: gpt-4o-mini
115
+ # api_key_env: OPENAI_API_KEY
116
+ retries: 2
117
+ temperature: 0.2
118
+
119
+ scoring:
120
+ weights:
121
+ novelty: 0.25
122
+ impact: 0.30
123
+ breadth: 0.20
124
+ recency: 0.25
125
+ boosts:
126
+ pricing_change: 0.15
127
+ deprecation: 0.15
128
+ security: 0.10
129
+ penalties:
130
+ non_shipment: 0.40
131
+ recency_half_life_days: 7
132
+ near_dup_threshold: 0.62
133
+ cluster_threshold: 0.28
134
+
135
+ brief:
136
+ top_n: 12
137
+ since: 7d
138
+ output_dir: out
139
+ html: false
140
+
141
+ fetch:
142
+ user_agent: "{DEFAULT_USER_AGENT}"
143
+ timeout_seconds: 30
144
+ respect_robots: true
145
+ min_interval_seconds: 1.0
146
+ max_items_per_source: 40
147
+ """
148
+
149
+
150
+ def load_yaml(path: Path) -> dict[str, Any]:
151
+ if not path.exists():
152
+ return {}
153
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
154
+ return data if isinstance(data, dict) else {}
155
+
156
+
157
+ def parse_sources(payload: Any) -> list[SourceSpec]:
158
+ rows = payload.get("sources", []) if isinstance(payload, dict) else payload
159
+ if not isinstance(rows, list):
160
+ raise ValueError("sources file must be a list or a mapping with a 'sources' key")
161
+ sources: list[SourceSpec] = []
162
+ for row in rows:
163
+ if not isinstance(row, dict):
164
+ raise ValueError("each source must be a mapping")
165
+ sources.append(
166
+ SourceSpec(
167
+ id=str(row["id"]),
168
+ name=str(row.get("name") or row["id"]),
169
+ kind=SourceKind(str(row["kind"])),
170
+ url=row.get("url"),
171
+ repo=row.get("repo"),
172
+ selector=row.get("selector"),
173
+ enabled=bool(row.get("enabled", True)),
174
+ tags=list(row.get("tags") or []),
175
+ notes=row.get("notes"),
176
+ )
177
+ )
178
+ return sources
179
+
180
+
181
+ def _weights(raw: dict[str, Any]) -> ScoringSettings:
182
+ weights = raw.get("weights") or {}
183
+ boosts = raw.get("boosts") or {}
184
+ penalties = raw.get("penalties") or {}
185
+ return ScoringSettings(
186
+ novelty=float(weights.get("novelty", 0.25)),
187
+ impact=float(weights.get("impact", 0.30)),
188
+ breadth=float(weights.get("breadth", 0.20)),
189
+ recency=float(weights.get("recency", 0.25)),
190
+ pricing_boost=float(boosts.get("pricing_change", 0.15)),
191
+ deprecation_boost=float(boosts.get("deprecation", 0.15)),
192
+ security_boost=float(boosts.get("security", 0.10)),
193
+ recency_half_life_days=float(raw.get("recency_half_life_days", 7)),
194
+ near_dup_threshold=float(raw.get("near_dup_threshold", 0.62)),
195
+ cluster_threshold=float(raw.get("cluster_threshold", 0.28)),
196
+ non_shipment_penalty=float(penalties.get("non_shipment", 0.40)),
197
+ )
198
+
199
+
200
+ def load_config(path: Path | None) -> AppConfig:
201
+ cfg = AppConfig(config_path=path)
202
+ data: dict[str, Any] = {}
203
+ if path and path.exists():
204
+ data = load_yaml(path)
205
+ root = path.parent
206
+ else:
207
+ root = Path.cwd()
208
+
209
+ cfg.data_dir = Path(
210
+ data.get("data_dir") or os.environ.get("SHIPWATCH_DATA_DIR") or cfg.data_dir
211
+ )
212
+ if not cfg.data_dir.is_absolute():
213
+ cfg.data_dir = (root / cfg.data_dir).resolve() if path else Path.cwd() / cfg.data_dir
214
+
215
+ sources_file = data.get("sources_file")
216
+ inline = data.get("sources")
217
+ if sources_file:
218
+ src_path = Path(sources_file)
219
+ if not src_path.is_absolute():
220
+ src_path = root / src_path
221
+ cfg.sources_file = src_path
222
+ cfg.sources = parse_sources(load_yaml(src_path))
223
+ elif inline:
224
+ cfg.sources = parse_sources(inline)
225
+ else:
226
+ cfg.sources = parse_sources(load_yaml(bundled_sources_path()))
227
+
228
+ llm = data.get("llm") or {}
229
+ cfg.llm = LLMSettings(
230
+ base_url=os.environ.get("SHIPWATCH_BASE_URL")
231
+ or llm.get("base_url")
232
+ or DEFAULT_FREEWHIRR_BASE,
233
+ model=os.environ.get("SHIPWATCH_MODEL") or llm.get("model") or DEFAULT_FREEWHIRR_MODEL,
234
+ api_key_env=llm.get("api_key_env") or "SHIPWATCH_API_KEY",
235
+ timeout_seconds=float(llm.get("timeout_seconds", 60)),
236
+ retries=int(llm.get("retries", 2)),
237
+ temperature=float(llm.get("temperature", 0.2)),
238
+ )
239
+ cfg.scoring = _weights(data.get("scoring") or {})
240
+ brief = data.get("brief") or {}
241
+ cfg.brief = BriefSettings(
242
+ top_n=int(brief.get("top_n", 12)),
243
+ since=str(brief.get("since", "7d")),
244
+ output_dir=Path(brief.get("output_dir") or "out"),
245
+ html=bool(brief.get("html", False)),
246
+ )
247
+ if not cfg.brief.output_dir.is_absolute():
248
+ cfg.brief.output_dir = (
249
+ (root / cfg.brief.output_dir).resolve() if path else Path.cwd() / cfg.brief.output_dir
250
+ )
251
+ fetch = data.get("fetch") or {}
252
+ cfg.fetch = FetchSettings(
253
+ user_agent=fetch.get("user_agent") or DEFAULT_USER_AGENT,
254
+ timeout_seconds=float(fetch.get("timeout_seconds", 30)),
255
+ respect_robots=bool(fetch.get("respect_robots", True)),
256
+ min_interval_seconds=float(fetch.get("min_interval_seconds", 1.0)),
257
+ max_items_per_source=int(fetch.get("max_items_per_source", 40)),
258
+ github_token_env=fetch.get("github_token_env") or "GITHUB_TOKEN",
259
+ )
260
+ return cfg
261
+
262
+
263
+ def write_init_files(directory: Path, *, force: bool = False) -> list[Path]:
264
+ directory.mkdir(parents=True, exist_ok=True)
265
+ written: list[Path] = []
266
+ config_path = directory / "shipwatch.yaml"
267
+ sources_path = directory / "sources.yaml"
268
+ if config_path.exists() and not force:
269
+ raise FileExistsError(f"{config_path} already exists (pass --force to overwrite)")
270
+ config_path.write_text(default_config_text(), encoding="utf-8")
271
+ written.append(config_path)
272
+ pack = bundled_sources_path().read_text(encoding="utf-8")
273
+ if sources_path.exists() and not force:
274
+ raise FileExistsError(f"{sources_path} already exists (pass --force to overwrite)")
275
+ sources_path.write_text(pack, encoding="utf-8")
276
+ written.append(sources_path)
277
+ return written
@@ -0,0 +1,172 @@
1
+ # Sample source pack (~26 well-known AI and developer-tool changelogs).
2
+ # URLs were fetched successfully on 2026-10-11 with the shipwatch user agent.
3
+ # Prefer RSS/Atom and GitHub Releases; HTML pages are a fallback and may be
4
+ # thin when a site is client-rendered.
5
+ #
6
+ # Edit freely. Disable a row with enabled: false.
7
+
8
+ sources:
9
+ - id: openai-news
10
+ name: OpenAI News
11
+ kind: rss
12
+ url: https://openai.com/news/rss.xml
13
+ tags: [openai, models]
14
+
15
+ - id: openai-api
16
+ name: OpenAI API Changelog
17
+ kind: html
18
+ url: https://developers.openai.com/api/docs/changelog
19
+ tags: [openai, api]
20
+
21
+ - id: anthropic-news
22
+ name: Anthropic News
23
+ kind: html
24
+ url: https://www.anthropic.com/news
25
+ tags: [anthropic, models]
26
+
27
+ - id: anthropic-release-notes
28
+ name: Anthropic Release Notes
29
+ kind: html
30
+ url: https://docs.anthropic.com/en/release-notes/overview
31
+ tags: [anthropic, api]
32
+ notes: Redirects to platform.claude.com as of 2026-10-11.
33
+
34
+ - id: google-ai-blog
35
+ name: Google AI Blog
36
+ kind: rss
37
+ url: https://blog.google/technology/ai/rss/
38
+ tags: [google, gemini]
39
+
40
+ - id: gemini-api
41
+ name: Gemini API Changelog
42
+ kind: html
43
+ url: https://ai.google.dev/gemini-api/docs/changelog
44
+ tags: [google, api]
45
+
46
+ - id: gemini-docs-sitemap
47
+ name: Gemini docs (new pages)
48
+ kind: sitemap
49
+ url: https://ai.google.dev/sitemap.xml
50
+ tags: [google, docs]
51
+ notes: Optional new-page detector. High volume; disable if noisy.
52
+
53
+ - id: mistral-news
54
+ name: Mistral News
55
+ kind: rss
56
+ url: https://mistral.ai/news/rss
57
+ tags: [mistral, models]
58
+
59
+ - id: cursor
60
+ name: Cursor Changelog
61
+ kind: html
62
+ url: https://cursor.com/changelog
63
+ tags: [cursor, editor]
64
+ notes: Marketing changelog; may be JS-rendered.
65
+
66
+ - id: vercel
67
+ name: Vercel
68
+ kind: rss
69
+ url: https://vercel.com/atom
70
+ tags: [vercel, platform]
71
+
72
+ - id: huggingface
73
+ name: Hugging Face Blog
74
+ kind: rss
75
+ url: https://huggingface.co/blog/feed.xml
76
+ tags: [huggingface, models]
77
+
78
+ - id: langchain
79
+ name: LangChain
80
+ kind: github
81
+ repo: langchain-ai/langchain
82
+ tags: [langchain, framework]
83
+
84
+ - id: openrouter
85
+ name: OpenRouter API Changelog
86
+ kind: html
87
+ url: https://openrouter.ai/docs/changelog
88
+ tags: [openrouter, api]
89
+
90
+ - id: llamaindex
91
+ name: LlamaIndex
92
+ kind: github
93
+ repo: run-llama/llama_index
94
+ tags: [llamaindex, framework]
95
+
96
+ - id: groq
97
+ name: Groq Changelog
98
+ kind: html
99
+ url: https://console.groq.com/docs/changelog
100
+ tags: [groq, inference]
101
+
102
+ - id: cohere
103
+ name: Cohere Changelog
104
+ kind: html
105
+ url: https://docs.cohere.com/changelog
106
+ tags: [cohere, models]
107
+
108
+ - id: pinecone
109
+ name: Pinecone Release Notes
110
+ kind: html
111
+ url: https://docs.pinecone.io/release-notes/2026
112
+ tags: [pinecone, retrieval]
113
+
114
+ - id: replicate
115
+ name: Replicate Changelog
116
+ kind: html
117
+ url: https://replicate.com/changelog
118
+ tags: [replicate, inference]
119
+
120
+ - id: cloudflare
121
+ name: Cloudflare Blog
122
+ kind: rss
123
+ url: https://blog.cloudflare.com/rss/
124
+ tags: [cloudflare, platform]
125
+
126
+ - id: nextjs
127
+ name: Next.js
128
+ kind: github
129
+ repo: vercel/next.js
130
+ tags: [vercel, nextjs]
131
+
132
+ - id: fastapi
133
+ name: FastAPI
134
+ kind: github
135
+ repo: fastapi/fastapi
136
+ tags: [python, api]
137
+
138
+ - id: pydantic
139
+ name: Pydantic
140
+ kind: github
141
+ repo: pydantic/pydantic
142
+ tags: [python, validation]
143
+
144
+ - id: supabase
145
+ name: Supabase Changelog
146
+ kind: html
147
+ url: https://supabase.com/changelog
148
+ tags: [supabase, platform]
149
+
150
+ - id: github-changelog
151
+ name: GitHub Changelog
152
+ kind: rss
153
+ url: https://github.blog/changelog/feed/
154
+ tags: [github, platform]
155
+
156
+ - id: xai
157
+ name: xAI Release Notes
158
+ kind: html
159
+ url: https://docs.x.ai/docs/release-notes
160
+ tags: [xai, models]
161
+
162
+ - id: deepseek
163
+ name: DeepSeek API Updates
164
+ kind: html
165
+ url: https://api-docs.deepseek.com/updates
166
+ tags: [deepseek, models]
167
+
168
+ - id: together
169
+ name: Together AI Changelog
170
+ kind: html
171
+ url: https://docs.together.ai/docs/changelog
172
+ tags: [together, inference]