Skip to main content

nexo_core/
link_understanding.rs

1//! Phase 21 — link understanding.
2//!
3//! When a user message contains URLs, the runtime fetches each one
4//! once per turn, extracts a short text summary, and renders a
5//! `# LINK CONTEXT` system block so the LLM has something to reason
6//! over instead of saying "I can't see what's at that link".
7//!
8//! Scope guarantees:
9//!
10//! - **Per-agent kill switch.** `agents.<id>.link_understanding.enabled`
11//!   defaults to `false`. Operators opt in.
12//! - **Hard caps everywhere.** `max_links_per_turn`, `max_bytes`,
13//!   request timeout, cache TTL, plus a privacy denylist of host
14//!   patterns the fetcher refuses outright.
15//! - **In-memory cache.** Keyed by URL, LRU with TTL. Cache hits
16//!   bypass network; misses race a single in-flight fetch.
17//! - **Naïve text extraction.** Strips HTML tags + collapses
18//!   whitespace + truncates. No DOM library — keeps the dep
19//!   surface small. A future revision can swap in `scraper` /
20//!   `readability`-style heuristics behind the same trait.
21//! - **Failure mode = silence.** Any fetch error (timeout, 4xx,
22//!   too big, blocked host) drops the URL from the rendered
23//!   block. The agent still sees the original message.
24
25use std::sync::{Arc, Mutex};
26use std::time::{Duration, Instant};
27
28use lru::LruCache;
29use serde::Deserialize;
30
31/// YAML schema. Lives under `agents.<id>.link_understanding`.
32#[derive(Debug, Clone, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct LinkUnderstandingConfig {
35    /// Master switch. `false` (default) = the runtime never fetches
36    /// anything; the agent sees URLs as plain text.
37    #[serde(default)]
38    pub enabled: bool,
39    /// Maximum URLs honoured per turn. Extras are silently dropped
40    /// (the agent still sees them in the original text).
41    #[serde(default = "default_max_links")]
42    pub max_links_per_turn: usize,
43    /// Hard cap on the response body. The fetcher streams until this
44    /// many bytes and then aborts the request — protects against a
45    /// hostile server feeding gigabytes of `/dev/random`.
46    #[serde(default = "default_max_bytes")]
47    pub max_bytes: usize,
48    /// Per-request HTTP timeout in milliseconds. Includes connection
49    /// + body read.
50    #[serde(default = "default_timeout_ms")]
51    pub timeout_ms: u64,
52    /// In-memory cache TTL in seconds. `0` = no caching (every link
53    /// hits the network, debugging only).
54    #[serde(default = "default_cache_ttl_secs")]
55    pub cache_ttl_secs: u64,
56    /// Host-suffix denylist. The fetcher refuses URLs whose host
57    /// ends in any of these (case-insensitive). Defaults block the
58    /// most common privacy footguns: localhost, link-local, RFC1918.
59    #[serde(default = "default_deny_hosts")]
60    pub deny_hosts: Vec<String>,
61}
62
63impl Default for LinkUnderstandingConfig {
64    fn default() -> Self {
65        Self {
66            enabled: false,
67            max_links_per_turn: default_max_links(),
68            max_bytes: default_max_bytes(),
69            timeout_ms: default_timeout_ms(),
70            cache_ttl_secs: default_cache_ttl_secs(),
71            deny_hosts: default_deny_hosts(),
72        }
73    }
74}
75
76fn default_max_links() -> usize {
77    3
78}
79fn default_max_bytes() -> usize {
80    1024 * 256 // 256 KiB — enough for a long article, not enough to DoS
81}
82fn default_timeout_ms() -> u64 {
83    8_000
84}
85fn default_cache_ttl_secs() -> u64 {
86    600
87}
88fn default_deny_hosts() -> Vec<String> {
89    vec![
90        "localhost".into(),
91        "127.0.0.1".into(),
92        "0.0.0.0".into(),
93        "169.254.0.0".into(), // AWS metadata link-local
94        "metadata.google.internal".into(),
95    ]
96}
97
98/// Cached extract entry.
99#[derive(Clone)]
100struct CacheEntry {
101    summary: Arc<str>,
102    inserted_at: Instant,
103}
104
105/// One link's extracted form, ready to render into the prompt.
106#[derive(Debug, Clone)]
107pub struct LinkSummary {
108    pub url: String,
109    pub title: Option<String>,
110    pub body: String,
111}
112
113/// In-memory cache + HTTP client. Held by the runtime as
114/// `Arc<LinkExtractor>` and shared across sessions; the extractor
115/// owns its rate limiter so concurrent turns don't stampede.
116pub struct LinkExtractor {
117    http: reqwest::Client,
118    cache: Mutex<LruCache<String, CacheEntry>>,
119    cache_ttl: Duration,
120    cache_capacity: usize,
121}
122
123const DEFAULT_CACHE_CAPACITY: usize = 256;
124
125impl LinkExtractor {
126    pub fn new(cfg: &LinkUnderstandingConfig) -> Self {
127        let http = reqwest::Client::builder()
128            .timeout(Duration::from_millis(cfg.timeout_ms))
129            .redirect(reqwest::redirect::Policy::limited(5))
130            .user_agent("nexo-link-understanding/0.1")
131            .build()
132            .unwrap_or_else(|e| {
133                tracing::warn!(error = %e, "link extractor: reqwest build failed; using default");
134                reqwest::Client::new()
135            });
136        Self {
137            http,
138            cache: Mutex::new(LruCache::new(
139                std::num::NonZeroUsize::new(DEFAULT_CACHE_CAPACITY).expect("cap > 0"),
140            )),
141            cache_ttl: Duration::from_secs(cfg.cache_ttl_secs),
142            cache_capacity: DEFAULT_CACHE_CAPACITY,
143        }
144    }
145
146    /// Capacity of the in-memory cache (for tests / diagnostics).
147    pub fn cache_capacity(&self) -> usize {
148        self.cache_capacity
149    }
150
151    /// Fetch + extract, honouring the cache. Returns `None` on any
152    /// error — the caller (llm_behavior) drops the URL from the
153    /// rendered block silently.
154    pub async fn fetch(&self, url: &str, cfg: &LinkUnderstandingConfig) -> Option<LinkSummary> {
155        if !cfg.enabled {
156            return None;
157        }
158        if !host_allowed(url, &cfg.deny_hosts) {
159            crate::telemetry::inc_link_fetch("blocked");
160            return None;
161        }
162
163        // Cache lookup with TTL check. We don't dedupe in-flight
164        // requests for the same URL — concurrent fetches are rare
165        // (one user, one turn) and adding an in-flight map would
166        // double the lock cost on the common path.
167        if cfg.cache_ttl_secs > 0 {
168            let mut cache = self.cache.lock().ok()?;
169            if let Some(entry) = cache.get(url) {
170                if entry.inserted_at.elapsed() < self.cache_ttl {
171                    crate::telemetry::inc_link_cache(true);
172                    return Some(LinkSummary {
173                        url: url.to_string(),
174                        title: None,
175                        body: entry.summary.to_string(),
176                    });
177                }
178            }
179            crate::telemetry::inc_link_cache(false);
180        }
181
182        let started = std::time::Instant::now();
183        let resp = match self.http.get(url).send().await {
184            Ok(r) => r,
185            Err(e) => {
186                let result = if e.is_timeout() { "timeout" } else { "error" };
187                crate::telemetry::inc_link_fetch(result);
188                crate::telemetry::observe_link_fetch_ms(started.elapsed().as_millis() as u64);
189                return None;
190            }
191        };
192        if !resp.status().is_success() {
193            crate::telemetry::inc_link_fetch("error");
194            crate::telemetry::observe_link_fetch_ms(started.elapsed().as_millis() as u64);
195            return None;
196        }
197        let content_type = resp
198            .headers()
199            .get(reqwest::header::CONTENT_TYPE)
200            .and_then(|v| v.to_str().ok())
201            .unwrap_or("")
202            .to_lowercase();
203        // Fetcher only understands HTML / plain text. PDFs / images
204        // / video are out of scope (Phase 24 will handle media).
205        if !content_type.contains("text/html")
206            && !content_type.contains("text/plain")
207            && !content_type.is_empty()
208        {
209            crate::telemetry::inc_link_fetch("non_html");
210            crate::telemetry::observe_link_fetch_ms(started.elapsed().as_millis() as u64);
211            return None;
212        }
213
214        let body = match read_capped(resp, cfg.max_bytes).await {
215            Ok(b) => b,
216            Err(_) => {
217                crate::telemetry::inc_link_fetch("error");
218                crate::telemetry::observe_link_fetch_ms(started.elapsed().as_millis() as u64);
219                return None;
220            }
221        };
222        let truncated = body.len() >= cfg.max_bytes;
223        let extracted = extract_main_text(&body, cfg.max_bytes);
224        if extracted.is_empty() {
225            let result = if truncated { "too_big" } else { "non_html" };
226            crate::telemetry::inc_link_fetch(result);
227            crate::telemetry::observe_link_fetch_ms(started.elapsed().as_millis() as u64);
228            return None;
229        }
230
231        if cfg.cache_ttl_secs > 0 {
232            if let Ok(mut cache) = self.cache.lock() {
233                cache.put(
234                    url.to_string(),
235                    CacheEntry {
236                        summary: Arc::from(extracted.as_str()),
237                        inserted_at: Instant::now(),
238                    },
239                );
240            }
241        }
242        crate::telemetry::inc_link_fetch("ok");
243        crate::telemetry::observe_link_fetch_ms(started.elapsed().as_millis() as u64);
244        Some(LinkSummary {
245            url: url.to_string(),
246            title: extract_title(&body),
247            body: extracted,
248        })
249    }
250}
251
252/// Detect URLs in arbitrary text. Returns deduped, in-order, capped
253/// at `max`. Tolerant of trailing punctuation in messages
254/// ("see https://x.com/a, then ..." drops the comma).
255pub fn detect_urls(text: &str, max: usize) -> Vec<String> {
256    // Hand-rolled scan instead of a heavy regex — `regex = "1"` is
257    // already in the dep tree, but a literal scan is faster on
258    // short user messages and avoids the catastrophic-backtracking
259    // risk of a complex URL regex on hostile input.
260    let mut out: Vec<String> = Vec::new();
261    let mut seen = std::collections::HashSet::new();
262    let mut i = 0;
263    let bytes = text.as_bytes();
264    while i < bytes.len() {
265        let rest = &text[i..];
266        let start_https = rest.find("https://");
267        let start_http = rest.find("http://");
268        let start = match (start_https, start_http) {
269            (Some(a), Some(b)) => Some(a.min(b)),
270            (a, b) => a.or(b),
271        };
272        let Some(rel) = start else { break };
273        let abs_start = i + rel;
274        let after = &text[abs_start..];
275        let end = after
276            .find(|c: char| c.is_whitespace() || c == '<' || c == '>' || c == '"' || c == '\'')
277            .unwrap_or(after.len());
278        let mut url = &after[..end];
279        // Strip trailing sentence punctuation that almost never
280        // belongs to the URL.
281        while let Some(stripped) = url
282            .strip_suffix(',')
283            .or_else(|| url.strip_suffix('.'))
284            .or_else(|| url.strip_suffix(';'))
285            .or_else(|| url.strip_suffix(':'))
286            .or_else(|| url.strip_suffix(')'))
287            .or_else(|| url.strip_suffix(']'))
288            .or_else(|| url.strip_suffix('}'))
289            .or_else(|| url.strip_suffix('?'))
290            .or_else(|| url.strip_suffix('!'))
291        {
292            url = stripped;
293        }
294        if url.len() > 2048 {
295            // Reject absurdly long URLs to keep the system block small.
296            i = abs_start + end;
297            continue;
298        }
299        if seen.insert(url.to_string()) {
300            out.push(url.to_string());
301            if out.len() >= max {
302                break;
303            }
304        }
305        i = abs_start + end;
306    }
307    out
308}
309
310fn host_allowed(url: &str, deny: &[String]) -> bool {
311    // Cheap lower-case host extraction without pulling in a full URL parser.
312    let after_scheme = url
313        .strip_prefix("https://")
314        .or_else(|| url.strip_prefix("http://"))
315        .unwrap_or(url);
316    let host = after_scheme
317        .split(['/', '?', '#'])
318        .next()
319        .unwrap_or("")
320        .split('@')
321        .next_back()
322        .unwrap_or("")
323        .split(':')
324        .next()
325        .unwrap_or("")
326        .to_lowercase();
327    if host.is_empty() {
328        return false;
329    }
330    !deny.iter().any(|pat| {
331        host == pat.to_lowercase() || host.ends_with(&format!(".{}", pat.to_lowercase()))
332    })
333}
334
335async fn read_capped(resp: reqwest::Response, cap: usize) -> Result<String, reqwest::Error> {
336    use futures::stream::StreamExt;
337    let mut stream = resp.bytes_stream();
338    let mut buf: Vec<u8> = Vec::with_capacity(cap.min(64 * 1024));
339    while let Some(chunk) = stream.next().await {
340        let chunk = chunk?;
341        let remaining = cap.saturating_sub(buf.len());
342        if remaining == 0 {
343            break;
344        }
345        let take = remaining.min(chunk.len());
346        buf.extend_from_slice(&chunk[..take]);
347        if buf.len() >= cap {
348            break;
349        }
350    }
351    Ok(String::from_utf8_lossy(&buf).into_owned())
352}
353
354/// Strip HTML tags, collapse whitespace, truncate. Naïve on purpose —
355/// works for ~80% of articles, fails gracefully on the rest. A
356/// future revision can replace this with a real readability pass.
357pub fn extract_main_text(html: &str, max_bytes: usize) -> String {
358    // Drop content-irrelevant tags. The set covers the universal
359    // boilerplate (`<script>`, `<style>`, `<noscript>`, `<head>`)
360    // plus the structural-but-not-article tags that sites emit
361    // around the main content (`<nav>`, `<header>`, `<footer>`,
362    // `<aside>`, `<form>`, `<button>`, `<menu>`, `<iframe>`,
363    // `<svg>`, `<dialog>`, `<template>`). Dropping them shrinks the
364    // prompt budget and stops the agent from anchoring on cookie
365    // banners / share-buttons / nav menus.
366    //
367    // Phase 21 L-2 — moves the extractor from "naive HTML stripper"
368    // to "readability-shaped boilerplate dropper" without pulling
369    // in the `scraper` crate. Real DOM-walk readability is the
370    // next-step upgrade if this still leaves noise on a specific
371    // site shape.
372    let mut cleaned = String::from(html);
373    for tag in [
374        "script", "style", "noscript", "head", "nav", "header", "footer", "aside", "form",
375        "button", "menu", "iframe", "svg", "dialog", "template",
376    ] {
377        cleaned = strip_block(&cleaned, tag);
378    }
379    // Class-based dropper: nuke `<div class="sidebar | comments |
380    // advert | ad | share | social | cookie | popup | newsletter |
381    // related-articles">` etc. Catches sites that put boilerplate
382    // in `<div>`s instead of semantic tags.
383    let cleaned = strip_blocks_by_class_keyword(
384        &cleaned,
385        &[
386            "sidebar",
387            "side-bar",
388            "comment",
389            "advert",
390            "advertisement",
391            "share",
392            "social",
393            "cookie",
394            "popup",
395            "newsletter",
396            "related-article",
397            "related-posts",
398            "navigation",
399            "breadcrumb",
400            "promo",
401            "subscribe",
402        ],
403    );
404
405    // Replace common block-level tags with newlines so paragraph
406    // breaks survive the tag strip.
407    let mut buf = String::with_capacity(cleaned.len());
408    for token in tokenize(&cleaned) {
409        match token {
410            Token::Text(s) => buf.push_str(s),
411            Token::Tag(name) => {
412                let lname = name.trim_start_matches('/').to_ascii_lowercase();
413                if matches!(
414                    lname.as_str(),
415                    "p" | "br"
416                        | "div"
417                        | "li"
418                        | "h1"
419                        | "h2"
420                        | "h3"
421                        | "h4"
422                        | "h5"
423                        | "h6"
424                        | "tr"
425                        | "section"
426                ) {
427                    buf.push('\n');
428                }
429            }
430        }
431    }
432
433    // Decode the four common HTML entities. A full entity table is
434    // overkill here.
435    let buf = buf
436        .replace("&nbsp;", " ")
437        .replace("&amp;", "&")
438        .replace("&lt;", "<")
439        .replace("&gt;", ">")
440        .replace("&quot;", "\"")
441        .replace("&#39;", "'");
442
443    // Collapse whitespace runs into single spaces / single \n.
444    let mut out = String::with_capacity(buf.len());
445    let mut prev_blank = true;
446    let mut blank_run = 0;
447    for line in buf.lines() {
448        let trimmed = line.trim();
449        if trimmed.is_empty() {
450            blank_run += 1;
451            if blank_run <= 1 && !prev_blank {
452                out.push('\n');
453            }
454            continue;
455        }
456        blank_run = 0;
457        if !prev_blank {
458            out.push('\n');
459        }
460        // Collapse interior whitespace runs.
461        let mut last_space = false;
462        for c in trimmed.chars() {
463            if c.is_whitespace() {
464                if !last_space {
465                    out.push(' ');
466                }
467                last_space = true;
468            } else {
469                out.push(c);
470                last_space = false;
471            }
472        }
473        prev_blank = false;
474    }
475
476    // Hard truncate by char (not byte) to avoid splitting UTF-8.
477    let max_chars = max_bytes / 2; // each char ≤ 4 bytes; conservative
478    if out.chars().count() > max_chars {
479        out = out.chars().take(max_chars).collect::<String>() + "…";
480    }
481    out
482}
483
484fn extract_title(html: &str) -> Option<String> {
485    let lower = html.to_ascii_lowercase();
486    let start = lower.find("<title")?;
487    let end_open = lower[start..].find('>')?;
488    let body_start = start + end_open + 1;
489    let close = lower[body_start..].find("</title")?;
490    let raw = &html[body_start..body_start + close];
491    let trimmed = raw.trim();
492    if trimmed.is_empty() {
493        None
494    } else {
495        Some(trimmed.chars().take(160).collect())
496    }
497}
498
499/// Drops every `<TAG class="…X…">…</TAG>` whose `class`, `id`,
500/// or `role` attribute contains any of the supplied keywords
501/// (case-insensitive). Walks until end-of-document; nested
502/// elements with non-matching classes that sit inside a stripped
503/// block are dropped along with the parent.
504///
505/// Tag-agnostic by design — sites use both `<div class="sidebar">`
506/// and `<aside class="sidebar">`. The earlier tag-name strip
507/// handles semantic tags; this catches the `<div>`s that should
508/// have been semantic but aren't.
509///
510/// Phase 21 L-2.
511fn strip_blocks_by_class_keyword(html: &str, keywords: &[&str]) -> String {
512    let lower = html.to_ascii_lowercase();
513    let mut out = String::with_capacity(html.len());
514    let mut cursor = 0usize;
515
516    while cursor < html.len() {
517        // Find the next opening tag with a `class` / `id` / `role`
518        // attribute matching one of our keywords.
519        let Some(open_rel) = lower[cursor..].find('<') else {
520            out.push_str(&html[cursor..]);
521            break;
522        };
523        let open_abs = cursor + open_rel;
524        let Some(end_rel) = lower[open_abs..].find('>') else {
525            out.push_str(&html[cursor..]);
526            break;
527        };
528        let tag_end = open_abs + end_rel + 1;
529        let tag_chunk = &lower[open_abs..tag_end];
530
531        // Skip if this is a closing tag — we deal with those when
532        // we eat a stripped block.
533        if tag_chunk.starts_with("</") {
534            out.push_str(&html[cursor..tag_end]);
535            cursor = tag_end;
536            continue;
537        }
538
539        // Detect tag name (the substring between `<` and the first
540        // whitespace or `>`).
541        let after_lt = &tag_chunk[1..];
542        let name_end = after_lt
543            .find(|c: char| c.is_whitespace() || c == '>' || c == '/')
544            .unwrap_or(after_lt.len());
545        let tag_name = &after_lt[..name_end];
546        if tag_name.is_empty() {
547            out.push_str(&html[cursor..tag_end]);
548            cursor = tag_end;
549            continue;
550        }
551
552        // Test the keyword set against `class`, `id`, `role`.
553        let mut matched = false;
554        for attr in ["class", "id", "role"] {
555            // Look for `attr="…"` or `attr='…'` inside the tag chunk.
556            if let Some(attr_pos) = tag_chunk.find(&format!(" {attr}=")) {
557                let after = &tag_chunk[attr_pos + attr.len() + 2..];
558                let quote = after.chars().next().unwrap_or('"');
559                if quote != '"' && quote != '\'' {
560                    continue;
561                }
562                let value_start = 1usize;
563                let value_end = after[value_start..]
564                    .find(quote)
565                    .map(|p| value_start + p)
566                    .unwrap_or(after.len());
567                let value = &after[value_start..value_end];
568                for kw in keywords {
569                    if value.contains(kw) {
570                        matched = true;
571                        break;
572                    }
573                }
574                if matched {
575                    break;
576                }
577            }
578        }
579
580        if !matched {
581            out.push_str(&html[cursor..tag_end]);
582            cursor = tag_end;
583            continue;
584        }
585
586        // Eat content up to the matching close tag for `tag_name`.
587        // Handle nesting: count opens minus closes of the same tag.
588        out.push_str(&html[cursor..open_abs]);
589        let close_pat = format!("</{tag_name}");
590        let open_pat_nested = format!("<{tag_name}");
591        let mut depth: i32 = 1;
592        let mut scan = tag_end;
593        while scan < html.len() && depth > 0 {
594            // Find next < of either same-tag-open or same-tag-close.
595            let next_close = lower[scan..].find(&close_pat).map(|p| scan + p);
596            let next_open = lower[scan..].find(&open_pat_nested).map(|p| scan + p);
597            match (next_open, next_close) {
598                (Some(o), Some(c)) if o < c => {
599                    let open_end = lower[o..]
600                        .find('>')
601                        .map(|p| o + p + 1)
602                        .unwrap_or(html.len());
603                    depth += 1;
604                    scan = open_end;
605                }
606                (_, Some(c)) => {
607                    let close_end = lower[c..]
608                        .find('>')
609                        .map(|p| c + p + 1)
610                        .unwrap_or(html.len());
611                    depth -= 1;
612                    scan = close_end;
613                }
614                _ => break,
615            }
616        }
617        cursor = scan;
618    }
619
620    out
621}
622
623fn strip_block(html: &str, tag: &str) -> String {
624    let lower = html.to_ascii_lowercase();
625    let open_pat = format!("<{tag}");
626    let close_pat = format!("</{tag}");
627    let mut out = String::with_capacity(html.len());
628    let mut cursor = 0;
629    while cursor < html.len() {
630        let Some(open_rel) = lower[cursor..].find(&open_pat) else {
631            out.push_str(&html[cursor..]);
632            break;
633        };
634        let open_abs = cursor + open_rel;
635        out.push_str(&html[cursor..open_abs]);
636        let after_open = lower[open_abs..].find('>').map(|p| open_abs + p + 1);
637        let Some(after) = after_open else { break };
638        let Some(close_rel) = lower[after..].find(&close_pat) else {
639            break;
640        };
641        let close_abs = after + close_rel;
642        let close_end = lower[close_abs..]
643            .find('>')
644            .map(|p| close_abs + p + 1)
645            .unwrap_or(html.len());
646        cursor = close_end;
647    }
648    out
649}
650
651enum Token<'a> {
652    Text(&'a str),
653    Tag(&'a str),
654}
655
656fn tokenize(html: &str) -> Vec<Token<'_>> {
657    let mut out = Vec::new();
658    let mut cursor = 0;
659    while cursor < html.len() {
660        let Some(open) = html[cursor..].find('<') else {
661            out.push(Token::Text(&html[cursor..]));
662            break;
663        };
664        if open > 0 {
665            out.push(Token::Text(&html[cursor..cursor + open]));
666        }
667        let tag_start = cursor + open + 1;
668        let Some(close) = html[tag_start..].find('>') else {
669            break;
670        };
671        let tag_end = tag_start + close;
672        // Tag name = up to the first whitespace or '>' or '/'.
673        let tag_slice = &html[tag_start..tag_end];
674        let name_end = tag_slice
675            .find(|c: char| c.is_whitespace())
676            .unwrap_or(tag_slice.len());
677        out.push(Token::Tag(&tag_slice[..name_end]));
678        cursor = tag_end + 1;
679    }
680    out
681}
682
683/// Render the `# LINK CONTEXT` system block. Empty `Vec` = empty
684/// string; caller must check before pushing into `system_parts`.
685pub fn render_block(summaries: &[LinkSummary]) -> String {
686    if summaries.is_empty() {
687        return String::new();
688    }
689    let mut out = String::from("# LINK CONTEXT\n\n");
690    out.push_str(
691        "The user's message included the following links. The runtime fetched each one and \
692         extracted a text summary so you can answer with grounded facts. Cite the link if you \
693         use it; do not invent details that aren't in the summary.\n\n",
694    );
695    for (idx, s) in summaries.iter().enumerate() {
696        out.push_str(&format!("## [{}] {}\n", idx + 1, s.url));
697        if let Some(title) = s.title.as_deref() {
698            out.push_str(&format!("Title: {title}\n"));
699        }
700        out.push('\n');
701        out.push_str(&s.body);
702        out.push_str("\n\n");
703    }
704    out
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710
711    #[test]
712    fn detect_picks_https_and_http_in_order_dedup() {
713        let txt = "see https://a.com/x and http://b.com? and https://a.com/x again";
714        let urls = detect_urls(txt, 10);
715        assert_eq!(urls, vec!["https://a.com/x", "http://b.com"]);
716    }
717
718    #[test]
719    fn detect_strips_trailing_punctuation() {
720        let urls = detect_urls("ok https://example.com/foo, bye.", 10);
721        assert_eq!(urls, vec!["https://example.com/foo"]);
722    }
723
724    #[test]
725    fn detect_caps_at_max() {
726        let urls = detect_urls("https://a.com https://b.com https://c.com https://d.com", 2);
727        assert_eq!(urls.len(), 2);
728        assert_eq!(urls[0], "https://a.com");
729    }
730
731    #[test]
732    fn detect_skips_hostile_long_urls() {
733        let huge = format!("https://a.com/{}", "x".repeat(3000));
734        let urls = detect_urls(&format!("look {huge} thanks"), 10);
735        assert!(urls.is_empty(), "URL > 2048 chars must be dropped");
736    }
737
738    #[test]
739    fn host_denylist_blocks_localhost_and_metadata() {
740        let deny = default_deny_hosts();
741        assert!(!host_allowed("http://localhost:8080/x", &deny));
742        assert!(!host_allowed("http://127.0.0.1/", &deny));
743        assert!(!host_allowed("http://metadata.google.internal/x", &deny));
744        assert!(!host_allowed(
745            "http://api.metadata.google.internal/x",
746            &deny
747        ));
748        assert!(host_allowed("https://example.com/x", &deny));
749    }
750
751    #[test]
752    fn extract_strips_scripts_and_styles() {
753        let html = "<html><head><title>T</title></head>\
754                    <body><script>alert(1)</script><p>Hello</p>\
755                    <style>.x{}</style><p>World</p></body></html>";
756        let out = extract_main_text(html, 4096);
757        assert!(out.contains("Hello"));
758        assert!(out.contains("World"));
759        assert!(!out.contains("alert"));
760        assert!(!out.contains(".x{}"));
761    }
762
763    // Phase 21 L-2 — readability-shaped boilerplate dropper.
764
765    #[test]
766    fn extract_drops_semantic_boilerplate_tags() {
767        let html = r#"<html><body>
768            <header>SiteName · Login · Cart</header>
769            <nav>Home | Blog | Contact</nav>
770            <main><article>
771                <h1>The Article</h1>
772                <p>Real content lives here.</p>
773            </article></main>
774            <aside>Related links sidebar noise</aside>
775            <footer>Copyright 2026 · privacy · cookies</footer>
776        </body></html>"#;
777        let out = extract_main_text(html, 4096);
778        assert!(out.contains("The Article"));
779        assert!(out.contains("Real content lives here"));
780        assert!(!out.contains("SiteName"), "stripped <header>");
781        assert!(!out.contains("Home | Blog"), "stripped <nav>");
782        assert!(!out.contains("Related links sidebar"), "stripped <aside>");
783        assert!(!out.contains("Copyright"), "stripped <footer>");
784    }
785
786    #[test]
787    fn extract_drops_class_marked_sidebars() {
788        let html = r#"<html><body>
789            <article><p>Article body.</p></article>
790            <div class="sidebar widget">Newsletter signup form</div>
791            <div class="related-articles">More to read</div>
792            <div id="comments-section">User comments here</div>
793        </body></html>"#;
794        let out = extract_main_text(html, 4096);
795        assert!(out.contains("Article body"));
796        assert!(!out.contains("Newsletter signup"));
797        assert!(!out.contains("More to read"));
798        assert!(!out.contains("User comments here"));
799    }
800
801    #[test]
802    fn extract_drops_role_navigation_blocks() {
803        let html = r#"<html><body>
804            <div role="navigation"><a href=/>Home</a></div>
805            <p>Main paragraph.</p>
806        </body></html>"#;
807        let out = extract_main_text(html, 4096);
808        assert!(out.contains("Main paragraph"));
809        assert!(!out.contains("Home"));
810    }
811
812    #[test]
813    fn extract_keeps_class_when_no_keyword_match() {
814        // Make sure the class-based stripper doesn't over-eagerly
815        // drop `<div>`s with innocent class names.
816        let html = r#"<html><body>
817            <div class="content article-body">The actual article.</div>
818            <div class="byline">By Author</div>
819        </body></html>"#;
820        let out = extract_main_text(html, 4096);
821        assert!(out.contains("The actual article"));
822        assert!(out.contains("By Author"));
823    }
824
825    #[test]
826    fn extract_drops_button_and_form_clutter() {
827        let html = r#"<html><body>
828            <form><input/><button>Subscribe</button></form>
829            <p>Article opener.</p>
830            <button>Share</button>
831        </body></html>"#;
832        let out = extract_main_text(html, 4096);
833        assert!(out.contains("Article opener"));
834        assert!(!out.contains("Subscribe"));
835        assert!(!out.contains("Share"));
836    }
837
838    #[test]
839    fn extract_title_from_head() {
840        let html = "<html><head><title>My Page</title></head><body>x</body></html>";
841        assert_eq!(extract_title(html).as_deref(), Some("My Page"));
842    }
843
844    #[test]
845    fn extract_handles_missing_title() {
846        let html = "<html><body>no title here</body></html>";
847        assert!(extract_title(html).is_none());
848    }
849
850    #[test]
851    fn render_block_lists_summaries() {
852        let s = vec![
853            LinkSummary {
854                url: "https://a.com".into(),
855                title: Some("A".into()),
856                body: "alpha body".into(),
857            },
858            LinkSummary {
859                url: "https://b.com".into(),
860                title: None,
861                body: "bravo body".into(),
862            },
863        ];
864        let out = render_block(&s);
865        assert!(out.contains("# LINK CONTEXT"));
866        assert!(out.contains("[1] https://a.com"));
867        assert!(out.contains("Title: A"));
868        assert!(out.contains("alpha body"));
869        assert!(out.contains("[2] https://b.com"));
870        assert!(out.contains("bravo body"));
871    }
872
873    #[test]
874    fn render_block_empty_yields_empty_string() {
875        assert_eq!(render_block(&[]), "");
876    }
877
878    #[test]
879    fn config_disabled_by_default() {
880        let cfg = LinkUnderstandingConfig::default();
881        assert!(!cfg.enabled);
882        assert_eq!(cfg.max_links_per_turn, 3);
883        assert_eq!(cfg.max_bytes, 256 * 1024);
884        assert!(cfg.deny_hosts.iter().any(|d| d == "localhost"));
885    }
886
887    #[tokio::test]
888    async fn fetch_skips_when_disabled() {
889        let cfg = LinkUnderstandingConfig::default(); // enabled = false
890        let ext = LinkExtractor::new(&cfg);
891        let r = ext.fetch("https://example.com/", &cfg).await;
892        assert!(r.is_none(), "must short-circuit when disabled");
893    }
894
895    #[tokio::test]
896    async fn fetch_skips_denylisted_host() {
897        let cfg = LinkUnderstandingConfig {
898            enabled: true,
899            ..LinkUnderstandingConfig::default()
900        };
901        let ext = LinkExtractor::new(&cfg);
902        // Even with enabled = true, localhost is on the deny list
903        // and we never attempt the fetch (so this test does not
904        // require a running server).
905        let r = ext.fetch("http://localhost:65530/", &cfg).await;
906        assert!(r.is_none());
907    }
908}