commit e4117ae2e6093824c9e414bccf79e1374c489e85
Author: Coder Agent <coder@agents.omni>
Date: Mon Feb 16 23:20:57 2026
Newsreader: n-gram clustering, digests, API skill
t-488.3: Replace single-word topic clustering with n-gram approach.
Cluster on bigrams/trigrams from titles weighted by TF-IDF, with
greedy dedup to avoid overlapping clusters. Filter boring common
words (days of week, generic terms). HTML-decode title entities.
t-488.4: Add skills/newsreader.md documenting the JSON API for
Ava. The API already existed (articles, topics, search, feeds,
ingest) — just needed docs.
t-488.6: Add Omni/Newsreader/Digest.hs for generating structured
digests of recent articles grouped by topic. Includes:
- GET /api/digest?hours=N endpoint (default 24h)
- CLI: newsreader digest --hours=48
- formatDigestText for plain text output (Telegram-ready)
- DigestTopic/DigestArticle types with JSON serialization
Task-Id: t-488.3, t-488.4, t-488.6
diff --git a/Omni/Newsreader.hs b/Omni/Newsreader.hs
index f8b7e0df..2570f34e 100755
--- a/Omni/Newsreader.hs
+++ b/Omni/Newsreader.hs
@@ -40,6 +40,7 @@ import qualified Data.Text as T
import qualified Network.Wai.Handler.Warp as Warp
import qualified Omni.Cli as Cli
import qualified Omni.Newsreader.Db as Db
+import qualified Omni.Newsreader.Digest as Digest
import qualified Omni.Newsreader.Feed as Feed
import qualified Omni.Newsreader.Ingest as Ingest
import qualified Omni.Newsreader.Web as Web
@@ -61,6 +62,7 @@ parser =
Cli.subparser
( Cli.command "serve" (Cli.info serveParser (Cli.progDesc "Start web server"))
<> Cli.command "ingest" (Cli.info ingestParser (Cli.progDesc "Fetch all feeds once"))
+ <> Cli.command "digest" (Cli.info digestParser (Cli.progDesc "Print a text digest of recent articles"))
)
serveParser :: Cli.Parser (IO ())
@@ -87,6 +89,16 @@ serveParser =
ingestParser :: Cli.Parser (IO ())
ingestParser = pure doIngest
+digestParser :: Cli.Parser (IO ())
+digestParser =
+ doDigest
+ </ Cli.option
+ Cli.auto
+ ( Cli.long "hours"
+ <> Cli.value (24 :: Int)
+ <> Cli.help "Hours to look back (default 24)"
+ )
+
-- | Start the web server with optional background ingestion.
doServe :: Int -> Int -> String -> IO ()
doServe port interval basePathStr = do
@@ -133,6 +145,14 @@ doIngest = do
Ingest.IngestFailure err ->
putText <| url <> ": FAILED: " <> err
+-- | Generate and print a text digest.
+doDigest :: Int -> IO ()
+doDigest hours = do
+ Db.initDb
+ Db.withDb <| \conn -> do
+ digest <- Digest.generateDigest conn hours
+ putText (Digest.formatDigestText digest)
+
test :: Test.Tree
test =
Test.group
diff --git a/Omni/Newsreader/Cluster.hs b/Omni/Newsreader/Cluster.hs
index 6ecc721a..cb1a8d50 100644
--- a/Omni/Newsreader/Cluster.hs
+++ b/Omni/Newsreader/Cluster.hs
@@ -1,10 +1,11 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE NoImplicitPrelude #-}
--- | Simple keyword-based topic clustering for news articles.
+-- | N-gram topic clustering for news articles.
--
--- Groups articles that share significant words in their titles.
--- No external dependencies (no embeddings/LLM needed).
+-- Groups articles by shared multi-word phrases (bigrams/trigrams)
+-- from titles, weighted by TF-IDF to surface meaningful topics
+-- rather than common single words.
--
-- : dep containers
module Omni.Newsreader.Cluster
@@ -28,172 +29,321 @@ data TopicCluster = TopicCluster
}
deriving (Show)
--- | Cluster articles by shared significant title words.
--- Returns clusters with 2+ articles, sorted by size (largest first).
--- Articles can appear in multiple clusters.
+instance Eq TopicCluster where
+ a == b = clusterLabel a == clusterLabel b
+
+-- | Cluster articles by shared n-gram phrases in titles.
+-- Returns clusters with 2+ articles, sorted by score (best first).
clusterArticles :: [Article.Article] -> [TopicCluster]
clusterArticles articles =
- let totalCount = length articles
- -- Build word -> articles index
- wordIndex = buildWordIndex articles
- -- Filter to significant words (2+ articles, <40% of total)
- maxFreq = max 2 (totalCount * 4 `div` 10)
- significant =
- Map.filter (\arts -> length arts >= 2 && length arts <= maxFreq) wordIndex
- -- Merge clusters with high overlap
- merged = mergeClusters (Map.toList significant)
- -- Sort by cluster size
- sorted = List.sortOn (negate <. length <. clusterItems) merged
- in sorted
-
--- | Build inverted index: significant word -> articles containing it.
-buildWordIndex :: [Article.Article] -> Map.Map Text [Article.Article]
-buildWordIndex articles =
+ let totalDocs = length articles
+ -- Build n-gram -> articles index (bigrams + trigrams)
+ ngramIndex = buildNgramIndex articles
+ -- Compute IDF scores for each n-gram
+ idfScores = computeIdf totalDocs ngramIndex
+ -- Filter: need 2+ articles, not too common (< 30% of corpus)
+ maxFreq = max 3 (totalDocs * 3 `div` 10)
+ candidates =
+ Map.filterWithKey
+ ( \ngram arts ->
+ length arts
+ >= 2
+ && length arts
+ <= maxFreq
+ && not (isBoringNgram ngram)
+ )
+ ngramIndex
+ -- Score each candidate by IDF * cluster_size
+ scored =
+ List.sortOn
+ (negate <. snd)
+ [ (ngram, idfScore * fromIntegral (length arts))
+ | (ngram, arts) <- Map.toList candidates,
+ let idfScore = Map.findWithDefault 0 ngram idfScores
+ ]
+ -- Greedily pick top clusters, merging overlapping ones
+ clusters = greedyClusters scored candidates
+ in clusters
+
+-- | Build inverted index: n-gram phrase -> articles containing it.
+buildNgramIndex :: [Article.Article] -> Map.Map Text [Article.Article]
+buildNgramIndex articles =
let pairs =
- [ (word, art)
+ [ (ngram, art)
| art <- articles,
- word <- titleWords (Article.articleTitle art)
+ ngram <- titleNgrams (Article.articleTitle art)
]
- in Map.fromListWith (++) [(w, [a]) | (w, a) <- pairs]
+ in Map.fromListWith (++) [(ng, [a]) | (ng, a) <- pairs]
+
+-- | Extract ngrams from a title: prefer bigrams, fall back to
+-- significant single words (5+ chars, not in common-word list).
+titleNgrams :: Text -> [Text]
+titleNgrams title =
+ let clean = htmlDecode title
+ ws = significantWords clean
+ bigrams = zipWith (\a b -> a <> " " <> b) ws (drop 1 ws)
+ trigrams = List.zipWith3 (\a b c -> a <> " " <> b <> " " <> c) ws (drop 1 ws) (drop 2 ws)
+ -- Single words: must be 5+ chars and not a common English word
+ singles = filter (\w -> T.length w >= 5 && w `Set.notMember` commonWords) ws
+ in List.nub (trigrams ++ bigrams ++ singles)
--- | Extract significant words from a title.
-titleWords :: Text -> [Text]
-titleWords =
- List.nub
- <. filter (\w -> T.length w >= 3 && w `Set.notMember` stopWords)
+-- | Decode common HTML entities in titles.
+htmlDecode :: Text -> Text
+htmlDecode =
+ T.replace "“" "\x201C"
+ <. T.replace "”" "\x201D"
+ <. T.replace "’" "\x2019"
+ <. T.replace "‘" "\x2018"
+ <. T.replace "&" "&"
+ <. T.replace "<" "<"
+ <. T.replace ">" ">"
+ <. T.replace """ "\""
+ <. T.replace "'" "'"
+
+-- | Words that are too common in news headlines to be useful topics
+-- (beyond stop words). These pass the stop word filter but don't
+-- convey topical meaning.
+commonWords :: Set.Set Text
+commonWords =
+ Set.fromList
+ [ "links",
+ "assorted",
+ "morning",
+ "breakfast",
+ "cereal",
+ "comic",
+ "quoting",
+ "saturday",
+ "sunday",
+ "monday",
+ "tuesday",
+ "wednesday",
+ "thursday",
+ "friday",
+ "daily",
+ "weekly",
+ "monthly",
+ "quest",
+ "review",
+ "notes",
+ "diary",
+ "things",
+ "stuff",
+ "today",
+ "yesterday",
+ "tomorrow",
+ "really",
+ "actually",
+ "going",
+ "using",
+ "people",
+ "world",
+ "years",
+ "think",
+ "great",
+ "doesn't",
+ "don't",
+ "can't",
+ "won't",
+ "right",
+ "better",
+ "might",
+ "looks",
+ "seems"
+ ]
+
+-- | Extract significant words (lowercase, no stop words, no short words).
+significantWords :: Text -> [Text]
+significantWords =
+ filter (\w -> T.length w >= 3 && w `Set.notMember` stopWords)
<. map T.toLower
<. T.words
- <. T.map (\c -> if Char.isAlphaNum c then c else ' ')
+ <. T.map (\c -> if Char.isAlphaNum c || c == '\'' then c else ' ')
+
+-- | Compute inverse document frequency for each n-gram.
+computeIdf :: Int -> Map.Map Text [Article.Article] -> Map.Map Text Double
+computeIdf totalDocs ngramIndex =
+ let n = max 1 totalDocs
+ in Map.map (\arts -> log (fromIntegral n / fromIntegral (length arts))) ngramIndex
--- | Merge clusters with >60% article overlap.
-mergeClusters :: [(Text, [Article.Article])] -> [TopicCluster]
-mergeClusters = go []
+-- | Greedily select top clusters, skipping n-grams whose articles
+-- are already well-covered by a higher-scoring cluster.
+greedyClusters ::
+ [(Text, Double)] ->
+ Map.Map Text [Article.Article] ->
+ [TopicCluster]
+greedyClusters scored candidates = go [] Set.empty scored
where
- go acc [] = acc
- go acc ((word, arts) : rest) =
- let artIds = Set.fromList (mapMaybe Article.articleId arts)
- -- Check if this overlaps significantly with an existing cluster
- merged = tryMerge word arts artIds acc
- in go merged rest
-
- tryMerge word arts artIds existing =
- case List.find (overlaps artIds) existing of
- Nothing ->
- -- New cluster
- TopicCluster word arts : existing
- Just cluster ->
- -- Merge into existing cluster
- let mergedArts = List.nubBy sameId (clusterItems cluster ++ arts)
- mergedLabel = clusterLabel cluster
- others = filter (/= cluster) existing
- in TopicCluster mergedLabel mergedArts : others
-
- overlaps ids1 cluster =
- let ids2 = Set.fromList (mapMaybe Article.articleId (clusterItems cluster))
- common = Set.size (Set.intersection ids1 ids2)
- smaller = min (Set.size ids1) (Set.size ids2)
- in smaller > 0 && common * 10 >= smaller * 6 -- 60% overlap
- sameId a b = Article.articleId a == Article.articleId b
+ maxClusters = 30
-instance Eq TopicCluster where
- a == b = clusterLabel a == clusterLabel b
+ go acc _ _ | length acc >= maxClusters = acc
+ go acc _ [] = acc
+ go acc covered ((ngram, _) : rest) =
+ case Map.lookup ngram candidates of
+ Nothing -> go acc covered rest
+ Just arts ->
+ let artIds = Set.fromList (mapMaybe Article.articleId arts)
+ -- How many of these articles are already covered?
+ newIds = Set.difference artIds covered
+ in -- Only form cluster if at least 2 articles are new
+ if Set.size newIds < 2
+ then go acc covered rest
+ else
+ let cluster =
+ TopicCluster
+ { clusterLabel = formatLabel ngram,
+ clusterItems = arts
+ }
+ covered' = Set.union covered artIds
+ in go (acc ++ [cluster]) covered' rest
+
+-- | Format a cluster label nicely (title case).
+formatLabel :: Text -> Text
+formatLabel = T.unwords <. map titleCase <. T.words
+ where
+ titleCase w
+ | w `Set.member` stopWords = w
+ | otherwise = case T.uncons w of
+ Nothing -> w
+ Just (c, rest') -> T.cons (Char.toUpper c) rest'
+
+-- | Filter out boring n-grams that are just stop word combinations
+-- or too generic to be meaningful topics.
+isBoringNgram :: Text -> Bool
+isBoringNgram ngram =
+ let ws = T.words ngram
+ allStop = all (`Set.member` stopWords) ws
+ -- An n-gram with all words < 4 chars is probably noise
+ allShort = all (\w -> T.length w < 4) ws
+ in allStop || allShort
--- | Common English stop words for filtering.
+-- | Common English stop words.
stopWords :: Set.Set Text
stopWords =
Set.fromList
- [ "the",
- "and",
- "for",
- "are",
- "but",
- "not",
+ [ -- Articles / determiners
+ "the",
+ "a",
+ "an",
+ "this",
+ "that",
+ "these",
+ "those",
+ -- Pronouns
+ "i",
"you",
- "all",
- "can",
- "has",
+ "he",
+ "she",
+ "it",
+ "we",
+ "they",
+ "me",
+ "him",
"her",
+ "us",
+ "them",
+ "my",
+ "your",
"his",
- "how",
"its",
- "may",
- "new",
- "now",
"our",
- "out",
- "say",
- "she",
- "too",
- "was",
+ "their",
"who",
- "did",
- "get",
- "had",
- "him",
- "let",
- "one",
- "own",
- "says",
- "said",
- "than",
- "that",
- "them",
- "then",
- "they",
- "this",
"what",
- "when",
- "will",
+ "which",
+ -- Prepositions
+ "in",
+ "on",
+ "at",
+ "to",
+ "for",
+ "of",
+ "by",
+ "from",
"with",
+ "into",
+ "about",
+ "over",
+ "after",
+ "under",
+ "between",
+ "through",
+ "during",
+ "before",
+ "against",
+ -- Conjunctions
+ "and",
+ "but",
+ "or",
+ "nor",
+ "so",
+ "yet",
+ -- Verbs (common/auxiliary)
+ "is",
+ "are",
+ "was",
+ "were",
+ "be",
"been",
- "from",
+ "being",
"have",
- "here",
- "just",
- "like",
- "make",
+ "has",
+ "had",
+ "do",
+ "does",
+ "did",
+ "will",
+ "would",
+ "could",
+ "should",
+ "may",
+ "might",
+ "can",
+ "shall",
+ -- Adverbs / misc
+ "not",
+ "no",
+ "all",
+ "any",
+ "some",
+ "every",
+ "each",
"more",
"most",
"much",
- "must",
- "over",
- "some",
- "such",
- "take",
- "into",
+ "many",
"very",
- "were",
- "your",
+ "just",
"also",
- "back",
- "been",
- "each",
+ "still",
"even",
- "many",
"only",
- "same",
- "about",
- "after",
- "could",
- "every",
- "first",
- "other",
- "should",
- "still",
- "their",
+ "now",
+ "then",
+ "here",
"there",
- "these",
- "those",
- "under",
- "being",
+ "when",
"where",
- "which",
- "while",
- "would",
- "through",
- "before",
- "between",
- "during",
- "because",
- "against"
+ "how",
+ "why",
+ "than",
+ "too",
+ "out",
+ "up",
+ "back",
+ "such",
+ "like",
+ "get",
+ "got",
+ "make",
+ "let",
+ "say",
+ "says",
+ "said",
+ "take",
+ "new",
+ "first",
+ "other",
+ "same",
+ "own"
]
diff --git a/Omni/Newsreader/Digest.hs b/Omni/Newsreader/Digest.hs
new file mode 100644
index 00000000..f647f481
--- /dev/null
+++ b/Omni/Newsreader/Digest.hs
@@ -0,0 +1,205 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+-- | Daily/weekly digest generation for the Newsreader.
+--
+-- Generates a structured summary of recent articles, grouped by
+-- topic clusters, suitable for display or sending via Telegram.
+--
+-- : dep sqlite-simple
+-- : dep aeson
+-- : dep time
+-- : dep containers
+module Omni.Newsreader.Digest
+ ( generateDigest,
+ Digest (..),
+ DigestTopic (..),
+ DigestArticle (..),
+ formatDigestText,
+ )
+where
+
+import Alpha
+import qualified Data.Aeson as Aeson
+import qualified Data.Map.Strict as Map
+import qualified Data.Text as T
+import qualified Data.Time as Time
+import qualified Database.SQLite.Simple as SQL
+import qualified Omni.Newsreader.Article as Article
+import qualified Omni.Newsreader.Cluster as Cluster
+import qualified Omni.Newsreader.Feed as Feed
+
+-- | A digest summarizing recent articles.
+data Digest = Digest
+ { digestPeriod :: Text,
+ digestFrom :: Time.UTCTime,
+ digestTo :: Time.UTCTime,
+ digestTopics :: [DigestTopic],
+ digestUnclustered :: [DigestArticle],
+ digestTotalArticles :: Int
+ }
+ deriving (Show)
+
+instance Aeson.ToJSON Digest where
+ toJSON d =
+ Aeson.object
+ [ "period" Aeson..= digestPeriod d,
+ "from" Aeson..= digestFrom d,
+ "to" Aeson..= digestTo d,
+ "topics" Aeson..= digestTopics d,
+ "other" Aeson..= digestUnclustered d,
+ "totalArticles" Aeson..= digestTotalArticles d
+ ]
+
+-- | A topic within a digest.
+data DigestTopic = DigestTopic
+ { dtLabel :: Text,
+ dtArticles :: [DigestArticle]
+ }
+ deriving (Show)
+
+instance Aeson.ToJSON DigestTopic where
+ toJSON dt =
+ Aeson.object
+ [ "label" Aeson..= dtLabel dt,
+ "articles" Aeson..= dtArticles dt
+ ]
+
+-- | A single article in a digest.
+data DigestArticle = DigestArticle
+ { daTitle :: Text,
+ daUrl :: Text,
+ daFeed :: Text,
+ daSnippet :: Text
+ }
+ deriving (Show)
+
+instance Aeson.ToJSON DigestArticle where
+ toJSON da =
+ Aeson.object
+ [ "title" Aeson..= daTitle da,
+ "url" Aeson..= daUrl da,
+ "feed" Aeson..= daFeed da,
+ "snippet" Aeson..= daSnippet da
+ ]
+
+-- | Generate a digest for the last N hours of articles.
+generateDigest :: SQL.Connection -> Int -> IO Digest
+generateDigest conn hours = do
+ now <- Time.getCurrentTime
+ let since = Time.addUTCTime (negate (fromIntegral hours * 3600)) now
+ -- Get recent articles (cap at 200 for performance)
+ allArticles <- Article.listRecentArticles conn 200
+ let recent = filter (isAfter since) allArticles
+ -- Get feed map for names
+ feeds <- Feed.listFeeds conn
+ let feedMap = Map.fromList [(fid, f) | f <- feeds, Just fid <- [Feed.feedId f]]
+ -- Cluster them
+ let clusters = Cluster.clusterArticles recent
+ -- Track which articles are in clusters
+ let clusteredIds =
+ foldMap
+ (foldMap (maybe mempty (: []) <. Article.articleId) <. Cluster.clusterItems)
+ clusters
+ unclustered =
+ filter
+ (maybe True (`notElem` clusteredIds) <. Article.articleId)
+ recent
+ -- Build digest
+ let topics =
+ [ DigestTopic
+ { dtLabel = Cluster.clusterLabel c,
+ dtArticles = map (toDigestArticle feedMap) (Cluster.clusterItems c)
+ }
+ | c <- clusters
+ ]
+ other = map (toDigestArticle feedMap) (take 10 unclustered)
+ period
+ | hours <= 24 = "daily"
+ | hours <= 168 = "weekly"
+ | otherwise = T.pack (show hours) <> "h"
+ pure
+ Digest
+ { digestPeriod = period,
+ digestFrom = since,
+ digestTo = now,
+ digestTopics = topics,
+ digestUnclustered = other,
+ digestTotalArticles = length recent
+ }
+
+isAfter :: Time.UTCTime -> Article.Article -> Bool
+isAfter since art =
+ case Article.articleFetchedAt art of
+ t -> t >= since
+
+toDigestArticle :: Map.Map Feed.FeedId Feed.Feed -> Article.Article -> DigestArticle
+toDigestArticle feedMap art =
+ let feedName = case Map.lookup (Article.articleFeedId art) feedMap of
+ Just f -> fromMaybe (Feed.feedUrl f) (Feed.feedTitle f)
+ Nothing -> "unknown"
+ snippet = T.take 150 (stripHtml (Article.articleContent art))
+ in DigestArticle
+ { daTitle = Article.articleTitle art,
+ daUrl = Article.articleUrl art,
+ daFeed = feedName,
+ daSnippet = snippet
+ }
+
+-- | Very basic HTML tag stripping.
+stripHtml :: Text -> Text
+stripHtml = go ""
+ where
+ go acc t = case T.uncons t of
+ Nothing -> T.strip acc
+ Just ('<', rest) -> go acc (T.drop 1 (T.dropWhile (/= '>') rest))
+ Just ('&', rest) ->
+ let (entity, after) = T.break (== ';') rest
+ in if T.null after
+ then go (acc <> "&" <> entity) after
+ else go (acc <> decodeEntity entity) (T.drop 1 after)
+ Just (c, rest) -> go (T.snoc acc c) rest
+
+ decodeEntity "amp" = "&"
+ decodeEntity "lt" = "<"
+ decodeEntity "gt" = ">"
+ decodeEntity "quot" = "\""
+ decodeEntity "#39" = "'"
+ decodeEntity "#8217" = "\x2019"
+ decodeEntity "#8220" = "\x201C"
+ decodeEntity "#8221" = "\x201D"
+ decodeEntity e = "&" <> e <> ";"
+
+-- | Format a digest as plain text (for Telegram or CLI).
+formatDigestText :: Digest -> Text
+formatDigestText d =
+ let header =
+ "📰 "
+ <> digestPeriod d
+ <> " digest — "
+ <> T.pack (show (digestTotalArticles d))
+ <> " articles\n\n"
+ topicSections =
+ T.concat
+ [ "▸ "
+ <> dtLabel t
+ <> " ("
+ <> T.pack (show (length (dtArticles t)))
+ <> ")\n"
+ <> T.concat
+ [ " • " <> daTitle a <> "\n " <> daUrl a <> "\n"
+ | a <- take 3 (dtArticles t)
+ ]
+ <> "\n"
+ | t <- take 10 (digestTopics d)
+ ]
+ otherSection =
+ if null (digestUnclustered d)
+ then ""
+ else
+ "▸ Other\n"
+ <> T.concat
+ [ " • " <> daTitle a <> " (" <> daFeed a <> ")\n"
+ | a <- take 5 (digestUnclustered d)
+ ]
+ in header <> topicSections <> otherSection
diff --git a/Omni/Newsreader/Web.hs b/Omni/Newsreader/Web.hs
index be66b645..932ad10b 100644
--- a/Omni/Newsreader/Web.hs
+++ b/Omni/Newsreader/Web.hs
@@ -39,6 +39,7 @@ import qualified Network.HTTP.Types.Header as Header
import qualified Network.Wai as Wai
import qualified Omni.Newsreader.Article as Article
import qualified Omni.Newsreader.Cluster as Cluster
+import qualified Omni.Newsreader.Digest as Digest
import qualified Omni.Newsreader.Feed as Feed
import qualified Omni.Newsreader.Ingest as Ingest
import qualified Omni.Newsreader.Search as Search
@@ -73,6 +74,7 @@ app basePath conn req respond = do
("GET", ["api", "feeds"]) -> apiFeeds conn respond
("POST", ["api", "feeds"]) -> apiAddFeed conn req respond
("POST", ["api", "ingest"]) -> apiIngest conn respond
+ ("GET", ["api", "digest"]) -> apiDigest conn req respond
_ -> respond (htmlResponse HTTP.status404 (errorPage "not found"))
-- | Build a prefixed path. @p "/news" "/topics"@ = @"/news/topics"@.
@@ -299,6 +301,13 @@ apiIngest conn respond = do
]
respond <| jsonResponse (Aeson.object ["results" Aeson..= summary])
+-- | GET /api/digest?hours=N — generate a digest (default 24h).
+apiDigest :: SQL.Connection -> Wai.Request -> (Wai.Response -> IO Wai.ResponseReceived) -> IO Wai.ResponseReceived
+apiDigest conn req respond = do
+ let hours = queryInt "hours" 24 req
+ digest <- Digest.generateDigest conn hours
+ respond <| jsonResponse digest
+
-- ============================================================================
-- HTML Components
-- ============================================================================
diff --git a/skills/newsreader.md b/skills/newsreader.md
new file mode 100644
index 00000000..56add355
--- /dev/null
+++ b/skills/newsreader.md
@@ -0,0 +1,81 @@
+# Newsreader
+
+Query and manage the Omni Newsreader via its JSON API.
+
+## API Base URL
+
+All endpoints are at `https://beryllium.oryx-ide.ts.net/news/api/`.
+
+## Endpoints
+
+### GET /api/articles?limit=N
+
+Recent articles (default 50). Returns array:
+
+```json
+[{"id": 123, "title": "...", "url": "...", "feed": "Feed Name",
+ "content": "...", "publishedAt": "2026-...", "fetchedAt": "2026-..."}]
+```
+
+### GET /api/article/:id
+
+Single article by ID.
+
+### GET /api/topics
+
+Topic clusters (grouped by shared phrases in titles). Returns array:
+
+```json
+[{"label": "Topic Name", "articles": [...]}]
+```
+
+### GET /api/search?q=QUERY
+
+Full-text search across articles. Returns array of articles.
+
+### GET /api/feeds
+
+List all subscribed feeds.
+
+### POST /api/feeds
+
+Add a feed. JSON body: `{"url": "https://example.com/feed.xml"}`.
+
+### POST /api/ingest
+
+Trigger feed ingestion. Returns summary of new/skipped/failed articles per feed.
+
+### GET /api/digest?hours=N
+
+Generate a digest of recent articles (default 24h), grouped by topic clusters. Returns:
+
+```json
+{"period": "daily", "totalArticles": 42, "from": "...", "to": "...",
+ "topics": [{"label": "Topic Name", "articles": [{"title": "...", "url": "...", "feed": "...", "snippet": "..."}]}],
+ "other": [...]}
+```
+
+## Examples
+
+```bash
+# Get latest 10 articles
+curl -s 'https://beryllium.oryx-ide.ts.net/news/api/articles?limit=10' | jq '.[].title'
+
+# Search for articles about AI
+curl -s 'https://beryllium.oryx-ide.ts.net/news/api/search?q=artificial+intelligence' | jq '.[].title'
+
+# Get current topic clusters
+curl -s 'https://beryllium.oryx-ide.ts.net/news/api/topics' | jq '.[].label'
+
+# Add a new RSS feed
+curl -s -X POST 'https://beryllium.oryx-ide.ts.net/news/api/feeds' \
+ -H 'Content-Type: application/json' \
+ -d '{"url": "https://example.com/rss.xml"}'
+```
+
+## When to Use
+
+- User asks about recent news or "what's happening"
+- User asks to search for articles on a topic
+- User wants to add a new feed subscription
+- Summarizing news topics or trends