← Back to task

Commit e4117ae2

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 "&#8220;" "\x201C"
+    <. T.replace "&#8221;" "\x201D"
+    <. T.replace "&#8217;" "\x2019"
+    <. T.replace "&#8216;" "\x2018"
+    <. T.replace "&amp;" "&"
+    <. T.replace "&lt;" "<"
+    <. T.replace "&gt;" ">"
+    <. T.replace "&quot;" "\""
+    <. T.replace "&#39;" "'"
+
+-- | 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