News
The full news stack: feed discovery, catalog management, preference-aware fetching, LLM story fusion, and static HTML rendering — plus a decoupled feedback server that closes the 👍/👎 loop between the rendered page and the workflows. WHAT IT DOES
Turns a pile of RSS/Atom feeds into a ranked, deduplicated, optionally
LLM-fused news digest, rendered as a static HTML page you can read or host.
It discovers feeds, keeps a catalog of them, fetches and deduplicates
articles, learns your preferences from interested/ignored feedback, and
remembers what you have read and seen so those articles are hidden on the
next render. Clusters related articles into one story, and — if you point
it at an OpenAI-compatible LLM — fuses each cluster into a single factual
story with citations. Prefer it over reading feeds by hand or wiring a
one-off script: every stage is a swamp method (so it composes, resumes, and
is auditable), the fetch is incremental, and the feedback page closes the
loop so the next run ranks what you actually read. A dedicated
@svendowideit/news-feedback workflow drains that feedback every 30
minutes, so a read/seen article disappears on the next render rather than
waiting for the 4-hourly fetch. It is LLM-optional: without an LLM it still
fetches, filters, and renders.
INSTALL
swamp extension pull @svendowideit/newsDEPENDENCIES
None required — the fetch, catalog, and rendering stages use only the swamp
CLI and the bundled deno binary. Three optional pieces: set an
llmModel/llmBaseUrl to enable LLM story fusion (any OpenAI-compatible
server); install @svendowideit/systemd-service to run the feedback
server as a systemd user service; and install @svendowideit/caddy to
reverse-proxy that service to news.<host>.<domain>. Without the optional
extensions the matching steps (ensure-feedback-server,
ensure-caddy-proxy) are skipped with a log.
RUN
# See every setting, its valid values, its default and its current value.
swamp model @svendowideit/news-reader method run setup local-news
# Fast path — feedback, fetch, dedupe, filter, generate HTML. Also ensures
# the feedback server (when systemd-service is installed) and its Caddy
# route news.<host>.<domain> (when caddy is installed).
swamp workflow run @svendowideit/news-fetch
# Feedback only — drains the feedback queue into preferences. Scheduled
# every 30 minutes (at :15/:45) so a read/seen article is hidden on the
# next page render without waiting for the next fetch. Run it by hand to
# consume feedback immediately.
swamp workflow run @svendowideit/news-feedback
# The full loop — discovery → catalog → fetch → filter → fuse → render.
swamp workflow run @svendowideit/news-full The model instances (local-news, feed-catalog, feed-discovery, feed-analysis)
are auto-registered by the workflows on first run — no manual
swamp model create needed.
CONFIGURE
Global arguments on the news-reader instance (all optional):
llmBaseUrl OpenAI-compatible LLM base (default local Ollama)
llmModel Model tag for fusion; empty (default "")
disables fusion
llmTemperature Sampling temperature (default 0.1)
fusionMinClusterSize Min articles per fusion (default 2)
citationRetentionDays How long citations age out (default 30)
llmConcurrency Max concurrent LLM calls (default 3)
maxFusions Cap on LLM calls per step (default 25)
llmTimeoutSec Per-call LLM timeout (s) (default 120)
llmFailureThreshold Server failures before halt (default 3)
feedbackServerHost Interface it binds to (default 127.0.0.1)
feedbackServerPort Feedback server port (default 8765)
feedbackServerServiceName systemd service name (default feedback-server)
caddyModelName Caddy model to read the news (default "" = auto)
host/domain from
caddyRouteHostname Override the proxied hostname (default "" = news.<host>.<domain>) The feedback server's bind host/port are these model globals — not workflow
inputs — so a scheduled news-fetch run can never rebind an existing
service. Set them with swamp model @svendowideit/news-reader method run setup local-news (validates and reports the exact change) then apply with
swamp model edit local-news; the matching feed-catalog instance takes
feedbackServerHost/feedbackServerPort too. The discovery and analysis
models each take their own small global args; see the README.
When @svendowideit/caddy is installed, ensure-caddy-proxy reads the host
and domain from the caddy model that already owns this machine (its base
domain plus the A record matching hostname) — nothing is hardcoded. It
declares the route on its own news-caddy model, which the caddy extension
merges with the models it already runs, so the main Caddy is not edited
directly. Pin a specific source model with caddyModelName, or the whole
hostname with caddyRouteHostname, on the local-news instance.
WHAT IT INSTALLS
Nothing by default. With @svendowideit/systemd-service installed it writes
the feedback server as a systemd user service and enables user lingering so
the server starts at boot; it binds the local-news
feedbackServerHost/feedbackServerPort globals (127.0.0.1:8765 by default,
local-only) — set them to expose it on the network, which is safe only
behind an authenticating proxy. Rewriting the unit does not restart a
running process, so after changing the host/port run stopService then
startService. With @svendowideit/caddy installed it additionally creates
a news-caddy model and declares a reverse-proxy route from
news.<host>.<domain> to the feedback server, letting Caddy issue the
certificate and (when a DNS provider is configured) the A record. Without
those extensions nothing is installed on the host. The rendered HTML,
stories, and feedback queue are written under ~/.swamp/news-pages/.
Verify the route once Caddy is serving it:
swamp model @svendowideit/caddy method run news-caddy audit caddy-status
swamp data get caddy-status audit --json | jq '.content.cadies[] | {target, desiredRoutes, inSync}' and open https://news../ (the host printed by the
ensure-caddy-proxy log line) to read the digest the feedback server serves.
Global Arguments
| Argument | Type | Description |
|---|---|---|
| llmBaseUrl? | string | Base URL of an OpenAI-compatible LLM server. The /v1/chat/completions path is used. Defaults to a local Ollama instance. |
| llmModel? | string | LLM model tag used for story fusion (Ollama model, e.g. llama3, mistral, qwen2.5). |
| llmApiKey? | string | Optional API key for LLM servers that require authentication. Ollama typically does not need one. |
| llmTemperature? | number | Sampling temperature for fusion LLM calls. Low values keep extraction deterministic. |
| fusionMinClusterSize? | number | Minimum cluster size to trigger LLM fusion. Singletons stay as ordinary summaries unless a story already exists. |
| citationRetentionDays? | number | How long article citations live before aging out of a story. Core facts always survive. |
| llmConcurrency? | number | Max concurrent LLM requests for fusion steps. Start at 1 and increase if the server supports it. |
| maxFusions? | number | Hard cap on the number of LLM fusion calls made within a single fusion step (fuseStories / seedStories / regenStories). Once reached, the remaining clusters are skipped and persisted as-is. |
| llmTimeoutSec? | number | Per-call timeout (seconds) for LLM chat/completions requests. Lower values surface server outages faster. |
| llmFailureThreshold? | number | Server-side LLM failures (outage / timeout / HTTP 5xx) tolerated in a step before the step halts. 1 = fail fast on the first server error. Client-side errors are not counted. |
| feedbackServerPort? | number | |
| feedbackServerHost? | string | |
| feedbackServerServiceName? | string | systemd user service name for the feedback server. |
| feedbackServerScriptPath? | string | Override the path to the bundled feedback-server.ts script. Defaults to the script bundled with this extension. |
| caddyModelName? | string | Name of the @svendowideit/caddy model to read the news host's base domain and host record from. Empty (default) auto-discovers the caddy model that owns this machine. Only used when @svendowideit/caddy is installed. |
| typesafeApiKey? | string | TypeSafe API key for Jev-based article category classification. Prefer a vault reference. |
| typesafeBaseUrl? | string | TypeSafe API root for Jev-based article category classification. |
| typesafeModel? | string | TypeSafe System One model for category classification. Defaults to Jev. |
| categoryThreshold? | number | Minimum Jev probability for an article category to be kept (multi-label). |
| newCategoryThreshold? | number | Minimum probability of `needs_new_category` required to ask the LLM for new categories. |
| caddyRouteHostname? | string | Override the full hostname the news pages are proxied under. Empty (default) derives `news.<host>.<domain>` from the caddy model's base domain and this machine's A record. |
| Argument | Type | Description |
|---|---|---|
| llmBaseUrl? | string | Base URL of an OpenAI-compatible LLM server (e.g. http://localhost:11434) |
| llmModel? | string | LLM model tag for story fusion. Empty = fusion disabled; set a tag to enable. |
| llmApiKey? | string | Optional API key for LLM servers that require authentication. |
| llmTemperature? | number | Sampling temperature (0..2). Low values keep extraction deterministic. |
| fusionMinClusterSize? | number | Minimum cluster size to trigger LLM fusion. |
| citationRetentionDays? | number | How long article citations live before aging out. |
| llmConcurrency? | number | Max parallel LLM requests. |
| maxFusions? | number | Hard cap on LLM fusion calls per step before the step stops and the workflow continues. |
| llmTimeoutSec? | number | Per-call LLM request timeout in seconds. |
| llmFailureThreshold? | number | Server-side LLM failures tolerated in a step before the step halts. |
| feedbackServerHost? | string | Interface the feedback server binds to (127.0.0.1 = local-only). |
| feedbackServerPort? | number | Port the feedback server listens on. |
| Argument | Type | Description |
|---|---|---|
| feeds? | array | RSS/Atom feed URLs to fetch — either string URLs or feed objects from news-feed-catalog |
| maxArticlesPerFeed? | number | Maximum articles to keep per feed (default 25) |
| Argument | Type | Description |
|---|---|---|
| newsAge? | string | Time range of news to show (e.g., 2h, 7d, 4w, 1m). Supports h (hours), d (days), w (weeks), m (months). Defaults to 3 days. |
| Argument | Type | Description |
|---|---|---|
| topN? | number | Number of articles to include in the HTML report (0 = all articles, default 0) |
| title? | string | Title for the HTML report page |
| Argument | Type | Description |
|---|---|---|
| topN? | number | Number of articles to include in the mobile HTML report (0 = all articles, default 0) |
| title? | string | Title for the mobile HTML report page |
| Argument | Type | Description |
|---|---|---|
| articleId | string | Article ID (hash of URL) |
| action | enum | Whether the user found this article interesting or ignored it |
| source? | string | Feed source name |
| title? | string | Article title |
| keywords? | array | Keywords/tags from the article |
| Argument | Type | Description |
|---|---|---|
| serverUrl? | string | URL of the feedback queue HTTP server. Defaults to the model's feedbackServerHost/feedbackServerPort globals. |
| batchSize? | number | Number of feedback entries to process per batch |
| maxBatches? | number | Maximum number of batches to process in one run |
| Argument | Type | Description |
|---|---|---|
| serverUrl? | string | URL of the pages queue HTTP server. Defaults to the model's feedbackServerHost/feedbackServerPort globals. |
| batchSize? | number | Number of queued pages to process per batch |
| maxBatches? | number | Maximum number of batches to process in one run |
| category? | string | Category tag applied to each page when adding to the catalog |
| Argument | Type | Description |
|---|---|---|
| snapshotName? | string | |
| maxClusterSize? | number |
| Argument | Type | Description |
|---|---|---|
| minClusterSize? | number |
| Argument | Type | Description |
|---|---|---|
| port? | number | Port the feedback server listens on (defaults to global feedbackServerPort). |
| host? | string | Interface to bind (defaults to global feedbackServerHost, i.e. 127.0.0.1). |
| serviceName? | string | systemd user service name (defaults to global feedbackServerServiceName). |
| scriptPath? | string | Override the path to the feedback-server.ts script (defaults to the bundled script). |
| Argument | Type | Description |
|---|---|---|
| model? | string | Source @svendowideit/caddy model whose base domain and host record supply the news hostname. Defaults to the global caddyModelName, else auto-discovers the caddy model that owns this machine. |
| proxyModel? | string | |
| hostname? | string | Full proxied hostname. Defaults to the global caddyRouteHostname, else news.<host>.<domain> from the source caddy model. |
| upstream? | string | Backend host:port to proxy to. Defaults to the feedback server's bind host/port (wildcard binds map to loopback). |
| label? | string |
Resources
Files
Global Arguments
| Argument | Type | Description |
|---|---|---|
| catalogName? | string | Catalog name (for multiple catalogs) |
| dedupeStalenessDays? | number | Max days before a cached feed identity is re-fetched during dedupe. New feeds are always fetched. |
| feedbackServerHost? | string | Interface the feedback server binds to. `127.0.0.1` (default) is local-only. |
| feedbackServerPort? | number | Port the feedback server listens on (default 8765). |
| Argument | Type | Description |
|---|---|---|
| url | string | RSS/Atom feed URL to add |
| category? | string | Category tag (e.g., tech, news, programming, podcasting) |
| name? | string | Human-readable feed name (defaults to hostname) |
| Argument | Type | Description |
|---|---|---|
| url? | string | Feed URL to add when the catalog is empty |
| category? | string | Category tag for the seeded feed |
| Argument | Type | Description |
|---|---|---|
| title? | string | Page title |
| prefs? | object | News-reader preferences used for per-feed seen/read/interested/ignored counts |
| snapshot? | object | News-reader feed snapshot used to map article IDs to feed sources |
| Argument | Type | Description |
|---|---|---|
| serverUrl? | string | URL of the feedback queue HTTP server. Defaults to this model's feedbackServerHost/feedbackServerPort globals. |
| batchSize? | number | Number of feed state entries to process per batch |
| maxBatches? | number | Maximum number of batches to process in one run |
| Argument | Type | Description |
|---|---|---|
| url | string | RSS/Atom feed URL to remove |
| Argument | Type | Description |
|---|---|---|
| category? | string | Filter by category (optional) |
| limit? | number | Maximum feeds to return. Use 0 for unlimited. |
Resources
Files
Global Arguments
| Argument | Type | Description |
|---|---|---|
| newsReaderModelId? | string | Model ID of the news-reader instance to read snapshots from (empty = find any) |
| feedCatalogModelId? | string | Model ID of the news-feed-catalog instance to upsert into (empty = find any) |
| Argument | Type | Description |
|---|---|---|
| maxSitesToCrawl? | number | Maximum new domains to crawl this run (default 10) |
| category? | string | Category tag for discovered feeds (default 'discovered') |
| dryRun? | boolean | If true, discover feeds but don't add them to the catalog (default false) |
| reCrawlAfterDays? | number | Domains crawled within this many days are skipped unless their feed is still unknown (default 7) |
| articleUrls? | array | Article URLs to crawl for new feeds (strings or {url} objects). When |
| nonFeedUrls? | array | URLs known to be HTML pages (not feeds) to crawl (strings or {url} |
Resources
Global Arguments
| Argument | Type | Description |
|---|---|---|
| newsReaderModelId? | string | Model ID of the news-reader instance whose pages-current data to read |
| Argument | Type | Description |
|---|---|---|
| maxPagesPerRun? | number | Maximum pages to analyze this run (default 50) |
| probeCommonPaths? | boolean | Probe common feed paths on pages that advertise no feed link (default true) |
| pages? | array | Page entries gathered by the news-reader's gatherPages method. When |
Resources
Fast-path news workflow — gathers feedback, fetches feeds from the catalog, deduplicates articles, filters by age, and generates the HTML report. Runs every 4 hours. No LLM calls, no catalog maintenance, no feed discovery. When @svendowideit/systemd-service is installed the feedback server is ensured as a systemd user service; when @svendowideit/caddy is installed a Caddy route is declared proxying it to news.<host>.<domain> (the host and domain come from the caddy model that already owns this
Feedback-only workflow — drains the feedback queue server and imports every pending entry (interested, ignored, seen, read) into the news-reader's preferences, then deletes the processed entries so the queue stays empty. Runs every 30 minutes (offset to :15/:45 so it never overlaps the top-of-hour news-fetch run). Decoupling feedback from the 4-hourly fetch means a read/seen article is hidden on the next page generation without waiting for the next full fetch. There is no fetching, filtering, r
LLM-driven story fusion — clusters filtered articles into same-story groups, fuses them into persistent stories via LLM delta passes, seeds new stories for fresh clusters, and renders stories to an inline HTML fragment. Runs every 12 hours. All steps allow failure — fusion is best-effort enrichment. The news workflow reads the rendered stories HTML from the datastore and includes it in the news page. Steps: cluster → fuse → seed → render
Feed catalog maintenance — deduplicates feeds by content identity, gathers feed enable/disable state from the feedback server, discovers new RSS feeds from article URLs, and analyzes queued pages for feed discovery. Runs daily at 3am. All steps allow failure — curation is best-effort maintenance. The news workflow reads the catalog's current state directly from the datastore. Steps: dedupe → gather-feed-state → discover → upsert-feed gather-pages → analyze-pages → upsert-page (independe
Full news feedback loop — gathers feedback from the queue server, fetches feeds from the catalog, generates an HTML summary ranked by your preferences, then discovers new RSS feeds from the article URLs and upserts them into the catalog for the next run, finally rendering feeds.html (a duplicate-grouped catalog listing). Loop: gather-feedback → dedupe (flag duplicate catalog feeds) → fetch → dedupe-articles (merge duplicate articles by URL) → filter by age (default: last 3 days) → cluster relat
Summary of the generated news HTML page with article count
- Has README or module doc2/2earned
- README has a code example1/1earned
- README is substantive1/1earned
- Most symbols documented1/1earned
- No slow types (deprecated)1/1earned
- Dependencies pass trust audit2/2earned
- Has description1/1earned
- Platform support declared (or universal)2/2earned
- License2/2earned
- Verified public repository2/2earned
Spawns a subprocess with Deno.Command().
- models/news_reader.ts:998 — no reason given