buildwithnexus 0.11.1 → 0.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -318,11 +318,14 @@ pub fn defs_for_context(include_subagent: bool, context_tokens: usize) -> Vec<To
318
318
  }
319
319
 
320
320
  // The compact surface advertises one canonical tool per capability — read,
321
- // write, edit, shell, glob, grep, list, artifact publishing, plus the
322
- // finish/question control tools and python_tool for local extensions. Pure
323
- // aliases (`read`/`write`/`edit`/`bash`, `publish_artifact`) still dispatch in
324
- // `run` if a model insists, but advertising duplicates wastes prompt tokens
325
- // and confuses weak models. Keep this list at 12 defs or fewer.
321
+ // write, edit, shell, glob, grep, list, artifact publishing, plus the finish
322
+ // control tool and python_tool for local extensions. The `question` tool is
323
+ // deliberately omitted: small models use it to stall (endlessly re-asking a
324
+ // clarifying question instead of building), so on the compact surface they must
325
+ // act on sensible defaults. Pure aliases (`read`/`write`/`edit`/`bash`,
326
+ // `publish_artifact`) still dispatch in `run` if a model insists, but
327
+ // advertising duplicates wastes prompt tokens and confuses weak models. Keep
328
+ // this list at 12 defs or fewer.
326
329
  fn compact_tool(name: &str) -> bool {
327
330
  matches!(
328
331
  name,
@@ -334,7 +337,6 @@ fn compact_tool(name: &str) -> bool {
334
337
  | "grep_files"
335
338
  | "list_dir"
336
339
  | "finish"
337
- | "question"
338
340
  | "Artifact"
339
341
  | "python_tool"
340
342
  )
@@ -1182,6 +1184,57 @@ fn first_local_script_src(contents: &str) -> Option<String> {
1182
1184
  None
1183
1185
  }
1184
1186
 
1187
+ // True if `val` is a local relative path (not an absolute URL or data: URI) —
1188
+ // such a reference won't exist next to a published single-file artifact.
1189
+ fn is_local_asset(val: &str) -> bool {
1190
+ let v = val.trim().to_ascii_lowercase();
1191
+ !v.is_empty()
1192
+ && !v.starts_with("http://")
1193
+ && !v.starts_with("https://")
1194
+ && !v.starts_with("//")
1195
+ && !v.starts_with("data:")
1196
+ }
1197
+
1198
+ // Extract a (possibly quoted) attribute value from the text right after `attr=`.
1199
+ fn attr_after(after: &str) -> String {
1200
+ let v = if let Some(rest) = after.strip_prefix('"') {
1201
+ rest.split('"').next().unwrap_or("")
1202
+ } else if let Some(rest) = after.strip_prefix('\'') {
1203
+ rest.split('\'').next().unwrap_or("")
1204
+ } else {
1205
+ after
1206
+ .split(|c: char| c.is_whitespace() || c == '>')
1207
+ .next()
1208
+ .unwrap_or("")
1209
+ };
1210
+ v.trim().to_string()
1211
+ }
1212
+
1213
+ // A `<link rel="stylesheet" href="local.css">` pointing at a local file that
1214
+ // won't exist next to the published artifact. Returns the local href, else None.
1215
+ fn first_local_stylesheet_href(contents: &str) -> Option<String> {
1216
+ let lower = contents.to_ascii_lowercase();
1217
+ let mut at = 0;
1218
+ while let Some(rel) = lower[at..].find("<link") {
1219
+ let tag_start = at + rel;
1220
+ let tag_end = lower[tag_start..]
1221
+ .find('>')
1222
+ .map(|e| tag_start + e)
1223
+ .unwrap_or(lower.len());
1224
+ let tag_lower = &lower[tag_start..tag_end];
1225
+ if tag_lower.contains("stylesheet") {
1226
+ if let Some(hp) = tag_lower.find("href=") {
1227
+ let val = attr_after(&contents[tag_start + hp + "href=".len()..tag_end]);
1228
+ if is_local_asset(&val) {
1229
+ return Some(val);
1230
+ }
1231
+ }
1232
+ }
1233
+ at = tag_end.max(tag_start + "<link".len());
1234
+ }
1235
+ None
1236
+ }
1237
+
1185
1238
  // Rejection reasons for artifact contents. Every message quotes the offending
1186
1239
  // snippet and the exact rule, plus what to change — cheap models can't fix
1187
1240
  // what they can't see.
@@ -1196,7 +1249,12 @@ fn artifact_quality_error(contents: &str, kind: &str) -> Option<String> {
1196
1249
  let trimmed_len = contents.trim().len();
1197
1250
  if trimmed_len < 300 {
1198
1251
  return Some(format!(
1199
- "HTML artifact is too small to be a complete runnable app: {trimmed_len} chars, but the minimum is 300. Resend the artifact with the full HTML document markup, embedded CSS, and embedded JavaScript — in `contents`."
1252
+ "HTML artifact is too small to be a complete runnable app: {trimmed_len} chars, but the minimum is 300. It must be ONE self-contained file: do NOT link external files (no <link rel=stylesheet> or <script src=…>); put ALL CSS inside a <style> block and ALL JavaScript — the full working logic — inside a <script> block. Resend the complete document in `contents`."
1253
+ ));
1254
+ }
1255
+ if let Some(href) = first_local_stylesheet_href(contents) {
1256
+ return Some(format!(
1257
+ "HTML artifact links a local stylesheet via <link rel=\"stylesheet\" href=\"{href}\">, which will not exist next to the published file. Move those styles into an inline <style>…</style> block so the artifact is self-contained."
1200
1258
  ));
1201
1259
  }
1202
1260
  if let Some(src) = first_local_script_src(contents) {
@@ -1490,14 +1548,8 @@ fn strip_html(html: &str) -> String {
1490
1548
  i += 1;
1491
1549
  }
1492
1550
 
1493
- // Collapse whitespace and decode common HTML entities.
1494
- let decoded = out
1495
- .replace("&amp;", "&")
1496
- .replace("&lt;", "<")
1497
- .replace("&gt;", ">")
1498
- .replace("&quot;", "\"")
1499
- .replace("&nbsp;", " ")
1500
- .replace("&#39;", "'");
1551
+ // Collapse whitespace and decode HTML entities (named + numeric).
1552
+ let decoded = decode_html_entities(&out);
1501
1553
 
1502
1554
  let mut result = String::new();
1503
1555
  let mut prev_ws = true;
@@ -1515,6 +1567,205 @@ fn strip_html(html: &str) -> String {
1515
1567
  result.trim().to_string()
1516
1568
  }
1517
1569
 
1570
+ // One parsed web-search result.
1571
+ struct SearchHit {
1572
+ title: String,
1573
+ url: String,
1574
+ snippet: String,
1575
+ }
1576
+
1577
+ // Percent-decode a URL-encoded string (also treats `+` as space). Invalid
1578
+ // escapes are left as-is. Bytes are reassembled as UTF-8 lossily.
1579
+ fn percent_decode(s: &str) -> String {
1580
+ let bytes = s.as_bytes();
1581
+ let mut out = Vec::with_capacity(bytes.len());
1582
+ let mut i = 0;
1583
+ while i < bytes.len() {
1584
+ match bytes[i] {
1585
+ b'%' if i + 2 < bytes.len() => {
1586
+ if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
1587
+ out.push(b);
1588
+ i += 3;
1589
+ continue;
1590
+ }
1591
+ out.push(b'%');
1592
+ i += 1;
1593
+ }
1594
+ b'+' => {
1595
+ out.push(b' ');
1596
+ i += 1;
1597
+ }
1598
+ b => {
1599
+ out.push(b);
1600
+ i += 1;
1601
+ }
1602
+ }
1603
+ }
1604
+ String::from_utf8_lossy(&out).into_owned()
1605
+ }
1606
+
1607
+ // Resolve a single entity body (the text between `&` and `;`) to its character:
1608
+ // the common named entities plus decimal (`#8217`) and hex (`#x2019`) numeric
1609
+ // character references. Returns None for anything unrecognized.
1610
+ fn entity_char(ent: &str) -> Option<char> {
1611
+ match ent {
1612
+ "amp" => Some('&'),
1613
+ "lt" => Some('<'),
1614
+ "gt" => Some('>'),
1615
+ "quot" => Some('"'),
1616
+ "apos" => Some('\''),
1617
+ "nbsp" => Some(' '),
1618
+ "mdash" => Some('—'),
1619
+ "ndash" => Some('–'),
1620
+ "hellip" => Some('…'),
1621
+ "rsquo" => Some('’'),
1622
+ "lsquo" => Some('‘'),
1623
+ "rdquo" => Some('”'),
1624
+ "ldquo" => Some('“'),
1625
+ _ => {
1626
+ let num = ent.strip_prefix('#')?;
1627
+ let code = match num.strip_prefix('x').or_else(|| num.strip_prefix('X')) {
1628
+ Some(hex) => u32::from_str_radix(hex, 16).ok()?,
1629
+ None => num.parse::<u32>().ok()?,
1630
+ };
1631
+ char::from_u32(code)
1632
+ }
1633
+ }
1634
+ }
1635
+
1636
+ // Decode HTML entities in a single left-to-right pass — named entities and
1637
+ // decimal/hex numeric character references. Single-pass so `&amp;lt;` decodes to
1638
+ // the literal `&lt;` (not `<`), and an unrecognized `&…;` is left untouched.
1639
+ fn decode_html_entities(s: &str) -> String {
1640
+ if !s.contains('&') {
1641
+ return s.to_string();
1642
+ }
1643
+ let mut out = String::with_capacity(s.len());
1644
+ let mut rest = s;
1645
+ while let Some(amp) = rest.find('&') {
1646
+ out.push_str(&rest[..amp]);
1647
+ let tail = &rest[amp..];
1648
+ // `&` and `;` are ASCII, so `semi` is a valid char boundary; the ≤12
1649
+ // bound keeps a lone `&` in prose from swallowing a distant `;`.
1650
+ if let Some(semi) = tail.find(';') {
1651
+ if semi <= 12 {
1652
+ if let Some(ch) = entity_char(&tail[1..semi]) {
1653
+ out.push(ch);
1654
+ rest = &tail[semi + 1..];
1655
+ continue;
1656
+ }
1657
+ }
1658
+ }
1659
+ out.push('&');
1660
+ rest = &tail[1..];
1661
+ }
1662
+ out.push_str(rest);
1663
+ out
1664
+ }
1665
+
1666
+ // Read attribute `attr` from the tag that opens at byte index `tag_start`,
1667
+ // scanning only within that tag (up to its closing `>`).
1668
+ fn tag_attr(html: &str, tag_start: usize, attr: &str) -> Option<String> {
1669
+ let end = html[tag_start..].find('>').map(|e| tag_start + e)?;
1670
+ let seg = &html[tag_start..end];
1671
+ let key = format!("{attr}=");
1672
+ let after = &seg[seg.find(&key)? + key.len()..];
1673
+ let quote = after.chars().next()?;
1674
+ if quote == '"' || quote == '\'' {
1675
+ let rest = &after[1..];
1676
+ rest.find(quote).map(|c| rest[..c].to_string())
1677
+ } else {
1678
+ let endv = after.find(char::is_whitespace).unwrap_or(after.len());
1679
+ Some(after[..endv].to_string())
1680
+ }
1681
+ }
1682
+
1683
+ // DuckDuckGo Lite wraps result links in a redirect: `//duckduckgo.com/l/?uddg=
1684
+ // <percent-encoded-target>&rut=…`. Recover the real destination; fall back to
1685
+ // normalizing a protocol-relative href.
1686
+ fn ddg_real_url(href: &str) -> String {
1687
+ let href = href.replace("&amp;", "&");
1688
+ if let Some(p) = href.find("uddg=") {
1689
+ let rest = &href[p + "uddg=".len()..];
1690
+ let end = rest.find('&').unwrap_or(rest.len());
1691
+ return percent_decode(&rest[..end]);
1692
+ }
1693
+ if let Some(stripped) = href.strip_prefix("//") {
1694
+ return format!("https://{stripped}");
1695
+ }
1696
+ href.to_string()
1697
+ }
1698
+
1699
+ // Parse DuckDuckGo Lite result rows into structured hits. Returns empty if the
1700
+ // markup doesn't match (the caller then falls back to a stripped-text blob).
1701
+ fn parse_ddg_lite(html: &str) -> Vec<SearchHit> {
1702
+ let mut titles: Vec<(String, String)> = Vec::new();
1703
+ let mut i = 0;
1704
+ while let Some(rel) = html[i..].find("<a ") {
1705
+ let tag_start = i + rel;
1706
+ let Some(gt) = html[tag_start..].find('>').map(|e| tag_start + e) else {
1707
+ break;
1708
+ };
1709
+ if html[tag_start..gt].contains("result-link") {
1710
+ let url = ddg_real_url(&tag_attr(html, tag_start, "href").unwrap_or_default());
1711
+ let after = &html[gt + 1..];
1712
+ if let Some(close) = after.to_lowercase().find("</a") {
1713
+ let title = decode_html_entities(&strip_html(&after[..close]))
1714
+ .trim()
1715
+ .to_string();
1716
+ if !title.is_empty() {
1717
+ titles.push((title, url));
1718
+ }
1719
+ }
1720
+ }
1721
+ i = gt + 1;
1722
+ }
1723
+
1724
+ let mut snippets: Vec<String> = Vec::new();
1725
+ let mut j = 0;
1726
+ while let Some(rel) = html[j..].find("result-snippet") {
1727
+ let pos = j + rel;
1728
+ if let Some(gt) = html[pos..].find('>') {
1729
+ let start = pos + gt + 1;
1730
+ if let Some(lt) = html[start..].find('<') {
1731
+ snippets.push(
1732
+ decode_html_entities(&strip_html(&html[start..start + lt]))
1733
+ .trim()
1734
+ .to_string(),
1735
+ );
1736
+ }
1737
+ }
1738
+ j = pos + "result-snippet".len();
1739
+ }
1740
+
1741
+ titles
1742
+ .into_iter()
1743
+ .enumerate()
1744
+ .map(|(k, (title, url))| SearchHit {
1745
+ title,
1746
+ url,
1747
+ snippet: snippets.get(k).cloned().unwrap_or_default(),
1748
+ })
1749
+ .collect()
1750
+ }
1751
+
1752
+ // Render parsed hits as compact, numbered `title / url / snippet` blocks that a
1753
+ // small model can read, capped at `max` results.
1754
+ fn format_search_hits(hits: &[SearchHit], max: usize) -> String {
1755
+ let mut out = String::new();
1756
+ for (n, h) in hits.iter().take(max).enumerate() {
1757
+ out.push_str(&format!("{}. {}\n", n + 1, h.title));
1758
+ if !h.url.is_empty() {
1759
+ out.push_str(&format!(" {}\n", h.url));
1760
+ }
1761
+ if !h.snippet.is_empty() {
1762
+ out.push_str(&format!(" {}\n", h.snippet));
1763
+ }
1764
+ out.push('\n');
1765
+ }
1766
+ out.trim_end().to_string()
1767
+ }
1768
+
1518
1769
  // Commands so destructive they require confirmation in every mode.
1519
1770
  pub fn catastrophic(cmd: &str) -> bool {
1520
1771
  let lower = cmd.to_lowercase();
@@ -1861,13 +2112,170 @@ fn xml_escape(s: &str) -> String {
1861
2112
  }
1862
2113
 
1863
2114
  fn docx_paragraph(text: &str, style: Option<&str>) -> String {
1864
- let style = style
2115
+ let ppr = style
1865
2116
  .map(|s| format!("<w:pPr><w:pStyle w:val=\"{s}\"/></w:pPr>"))
1866
2117
  .unwrap_or_default();
1867
- format!(
1868
- "<w:p>{style}<w:r><w:t>{}</w:t></w:r></w:p>",
2118
+ format!("<w:p>{ppr}{}</w:p>", docx_runs(text))
2119
+ }
2120
+
2121
+ // Emit one Word run for `text` with the active inline formatting. Empty text
2122
+ // produces nothing (an all-empty paragraph is still valid OOXML).
2123
+ fn docx_push_run(out: &mut String, text: &str, bold: bool, italic: bool, code: bool) {
2124
+ if text.is_empty() {
2125
+ return;
2126
+ }
2127
+ let mut rpr = String::new();
2128
+ if bold {
2129
+ rpr.push_str("<w:b/>");
2130
+ }
2131
+ if italic {
2132
+ rpr.push_str("<w:i/>");
2133
+ }
2134
+ if code {
2135
+ rpr.push_str("<w:rFonts w:ascii=\"Consolas\" w:hAnsi=\"Consolas\" w:cs=\"Consolas\"/>");
2136
+ }
2137
+ if !rpr.is_empty() {
2138
+ rpr = format!("<w:rPr>{rpr}</w:rPr>");
2139
+ }
2140
+ out.push_str("<w:r>");
2141
+ out.push_str(&rpr);
2142
+ out.push_str(&format!(
2143
+ "<w:t xml:space=\"preserve\">{}</w:t></w:r>",
1869
2144
  xml_escape(text)
1870
- )
2145
+ ));
2146
+ }
2147
+
2148
+ // Light-markdown inline formatting → Word runs: `**bold**`, `*italic*`, and
2149
+ // `` `code` `` (verbatim). Emphasis markers only toggle when they flank
2150
+ // non-space text, so arithmetic like `5 * 3` and glob patterns stay literal;
2151
+ // underscores are deliberately left alone so `snake_case` isn't italicized.
2152
+ fn docx_runs(text: &str) -> String {
2153
+ let chars: Vec<char> = text.chars().collect();
2154
+ let n = chars.len();
2155
+ let mut out = String::new();
2156
+ let mut buf = String::new();
2157
+ let (mut bold, mut italic, mut code) = (false, false, false);
2158
+ let mut i = 0;
2159
+ while i < n {
2160
+ let c = chars[i];
2161
+ // Inside a code span everything is literal until the closing backtick.
2162
+ if code {
2163
+ if c == '`' {
2164
+ docx_push_run(&mut out, &buf, bold, italic, code);
2165
+ buf.clear();
2166
+ code = false;
2167
+ } else {
2168
+ buf.push(c);
2169
+ }
2170
+ i += 1;
2171
+ continue;
2172
+ }
2173
+ match c {
2174
+ '`' => {
2175
+ docx_push_run(&mut out, &buf, bold, italic, code);
2176
+ buf.clear();
2177
+ code = true;
2178
+ i += 1;
2179
+ }
2180
+ '*' if i + 1 < n && chars[i + 1] == '*' => {
2181
+ // Opening `**` must precede non-space; closing must follow it.
2182
+ let flanks = if bold {
2183
+ i > 0 && !chars[i - 1].is_whitespace()
2184
+ } else {
2185
+ i + 2 < n && !chars[i + 2].is_whitespace()
2186
+ };
2187
+ if flanks {
2188
+ docx_push_run(&mut out, &buf, bold, italic, code);
2189
+ buf.clear();
2190
+ bold = !bold;
2191
+ } else {
2192
+ buf.push('*');
2193
+ buf.push('*');
2194
+ }
2195
+ i += 2;
2196
+ }
2197
+ '*' => {
2198
+ let flanks = if italic {
2199
+ i > 0 && !chars[i - 1].is_whitespace()
2200
+ } else {
2201
+ i + 1 < n && !chars[i + 1].is_whitespace()
2202
+ };
2203
+ if flanks {
2204
+ docx_push_run(&mut out, &buf, bold, italic, code);
2205
+ buf.clear();
2206
+ italic = !italic;
2207
+ } else {
2208
+ buf.push('*');
2209
+ }
2210
+ i += 1;
2211
+ }
2212
+ _ => {
2213
+ buf.push(c);
2214
+ i += 1;
2215
+ }
2216
+ }
2217
+ }
2218
+ docx_push_run(&mut out, &buf, bold, italic, code);
2219
+ out
2220
+ }
2221
+
2222
+ // Split a markdown table line into trimmed cells, dropping the outer pipes.
2223
+ fn parse_table_cells(line: &str) -> Vec<String> {
2224
+ let t = line.trim();
2225
+ let t = t.strip_prefix('|').unwrap_or(t);
2226
+ let t = t.strip_suffix('|').unwrap_or(t);
2227
+ t.split('|').map(|c| c.trim().to_string()).collect()
2228
+ }
2229
+
2230
+ // A markdown table row: trimmed, starts with `|`, and has at least two pipes.
2231
+ fn is_table_row(line: &str) -> bool {
2232
+ let t = line.trim();
2233
+ t.starts_with('|') && t.matches('|').count() >= 2
2234
+ }
2235
+
2236
+ // The `|---|:--:|` alignment row: every cell is only dashes/colons.
2237
+ fn is_table_separator(line: &str) -> bool {
2238
+ let cells = parse_table_cells(line);
2239
+ !cells.is_empty()
2240
+ && cells
2241
+ .iter()
2242
+ .all(|c| !c.is_empty() && c.chars().all(|ch| ch == '-' || ch == ':'))
2243
+ }
2244
+
2245
+ // One table cell. Header cells are a single bold run; body cells get full
2246
+ // inline markdown formatting.
2247
+ fn docx_table_cell(text: &str, header: bool) -> String {
2248
+ let runs = if header {
2249
+ let mut s = String::new();
2250
+ docx_push_run(&mut s, text, true, false, false);
2251
+ s
2252
+ } else {
2253
+ docx_runs(text)
2254
+ };
2255
+ format!("<w:tc><w:tcPr><w:tcW w:w=\"0\" w:type=\"auto\"/></w:tcPr><w:p>{runs}</w:p></w:tc>")
2256
+ }
2257
+
2258
+ // A bordered Word table. The first row is treated as the (bold) header.
2259
+ fn docx_table(rows: &[Vec<String>]) -> String {
2260
+ let mut out = String::from(
2261
+ "<w:tbl><w:tblPr><w:tblW w:w=\"0\" w:type=\"auto\"/><w:tblBorders>\
2262
+ <w:top w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>\
2263
+ <w:left w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>\
2264
+ <w:bottom w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>\
2265
+ <w:right w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>\
2266
+ <w:insideH w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>\
2267
+ <w:insideV w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>\
2268
+ </w:tblBorders></w:tblPr>",
2269
+ );
2270
+ for (r, row) in rows.iter().enumerate() {
2271
+ out.push_str("<w:tr>");
2272
+ for cell in row {
2273
+ out.push_str(&docx_table_cell(cell, r == 0));
2274
+ }
2275
+ out.push_str("</w:tr>");
2276
+ }
2277
+ out.push_str("</w:tbl>");
2278
+ out
1871
2279
  }
1872
2280
 
1873
2281
  fn docx_document(title: &str, body: &str) -> String {
@@ -1875,8 +2283,25 @@ fn docx_document(title: &str, body: &str) -> String {
1875
2283
  r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>"#,
1876
2284
  );
1877
2285
  out.push_str(&docx_paragraph(title, Some("Title")));
1878
- for line in body.lines() {
1879
- let trimmed = line.trim();
2286
+ let lines: Vec<&str> = body.lines().collect();
2287
+ let mut idx = 0;
2288
+ while idx < lines.len() {
2289
+ // A run of consecutive `|`-delimited rows becomes one Word table; the
2290
+ // alignment separator row is dropped and the first row is the header.
2291
+ if is_table_row(lines[idx]) {
2292
+ let mut rows: Vec<Vec<String>> = Vec::new();
2293
+ while idx < lines.len() && is_table_row(lines[idx]) {
2294
+ if !is_table_separator(lines[idx]) {
2295
+ rows.push(parse_table_cells(lines[idx]));
2296
+ }
2297
+ idx += 1;
2298
+ }
2299
+ if !rows.is_empty() {
2300
+ out.push_str(&docx_table(&rows));
2301
+ }
2302
+ continue;
2303
+ }
2304
+ let trimmed = lines[idx].trim();
1880
2305
  if trimmed.is_empty() {
1881
2306
  out.push_str("<w:p/>");
1882
2307
  } else if let Some(h) = trimmed.strip_prefix("### ") {
@@ -1890,6 +2315,7 @@ fn docx_document(title: &str, body: &str) -> String {
1890
2315
  } else {
1891
2316
  out.push_str(&docx_paragraph(trimmed, None));
1892
2317
  }
2318
+ idx += 1;
1893
2319
  }
1894
2320
  out.push_str(r#"<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr></w:body></w:document>"#);
1895
2321
  out
@@ -2983,7 +3409,20 @@ pub fn run(name: &str, input: &Value, cwd: &Path) -> Outcome {
2983
3409
  .set("User-Agent", "Mozilla/5.0 (compatible; buildwithnexus/1.0)"),
2984
3410
  ) {
2985
3411
  Ok(resp) => match resp.into_string() {
2986
- Ok(html) => ok(truncate(strip_html(&html), MAX_OUT)),
3412
+ Ok(html) => {
3413
+ let hits = parse_ddg_lite(&html);
3414
+ if hits.is_empty() {
3415
+ // Markup didn't match — fall back to the raw text so
3416
+ // the model still gets something.
3417
+ ok(truncate(strip_html(&html), MAX_OUT))
3418
+ } else {
3419
+ let body = format_search_hits(&hits, 10);
3420
+ ok(truncate(
3421
+ format!("{} results for \"{query}\":\n\n{body}", hits.len()),
3422
+ MAX_OUT,
3423
+ ))
3424
+ }
3425
+ }
2987
3426
  Err(e) => err(format!("search response error: {e}")),
2988
3427
  },
2989
3428
  Err(e) => err(format!("web search failed: {e}")),
@@ -3802,6 +4241,178 @@ mod tests {
3802
4241
  use super::*;
3803
4242
  use std::path::PathBuf;
3804
4243
 
4244
+ // ── web search parsing ──────────────────────────────────────────────────
4245
+ const DDG_LITE_SAMPLE: &str = r#"<html><body><form>search</form><table>
4246
+ <tr><td>1.&nbsp;</td><td>
4247
+ <a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fdoc.rust-lang.org%2Fstd%2F&amp;rut=abc" class='result-link'>Rust std docs</a>
4248
+ </td></tr>
4249
+ <tr><td>&nbsp;</td><td class='result-snippet'>The Rust Standard Library &amp; API reference.</td></tr>
4250
+ <tr><td>2.&nbsp;</td><td>
4251
+ <a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fcrates.io%2F&amp;rut=def" class='result-link'>crates.io</a>
4252
+ </td></tr>
4253
+ <tr><td>&nbsp;</td><td class='result-snippet'>The Rust package registry.</td></tr>
4254
+ </table></body></html>"#;
4255
+
4256
+ #[test]
4257
+ fn percent_decode_handles_escapes_and_plus() {
4258
+ assert_eq!(percent_decode("a%2Fb%20c+d"), "a/b c d");
4259
+ // A malformed trailing escape is left literal, not dropped.
4260
+ assert_eq!(percent_decode("100%"), "100%");
4261
+ }
4262
+
4263
+ #[test]
4264
+ fn decode_html_entities_single_pass_no_double_decode() {
4265
+ // Single pass: &amp; becomes a literal & and the following "lt;" is left
4266
+ // alone, so this must not collapse to "<".
4267
+ assert_eq!(decode_html_entities("a &amp;lt; b"), "a &lt; b");
4268
+ assert_eq!(
4269
+ decode_html_entities("x &lt;y&gt; &quot;z&quot;"),
4270
+ "x <y> \"z\""
4271
+ );
4272
+ }
4273
+
4274
+ #[test]
4275
+ fn decode_html_entities_resolves_numeric_and_typographic() {
4276
+ // Decimal and hex numeric references for a curly apostrophe.
4277
+ assert_eq!(decode_html_entities("it&#8217;s"), "it’s");
4278
+ assert_eq!(decode_html_entities("it&#x2019;s"), "it’s");
4279
+ // Common typographic named entities.
4280
+ assert_eq!(decode_html_entities("a &mdash; b &hellip;"), "a — b …");
4281
+ }
4282
+
4283
+ #[test]
4284
+ fn decode_html_entities_leaves_unknown_and_bare_amp_literal() {
4285
+ assert_eq!(decode_html_entities("Tom & Jerry"), "Tom & Jerry");
4286
+ assert_eq!(decode_html_entities("&notreal;"), "&notreal;");
4287
+ // A `&` with no nearby `;` is untouched.
4288
+ assert_eq!(
4289
+ decode_html_entities("cats & dogs everywhere"),
4290
+ "cats & dogs everywhere"
4291
+ );
4292
+ }
4293
+
4294
+ #[test]
4295
+ fn ddg_real_url_recovers_target_from_redirect() {
4296
+ assert_eq!(
4297
+ ddg_real_url("//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fa&amp;rut=z"),
4298
+ "https://example.com/a"
4299
+ );
4300
+ assert_eq!(ddg_real_url("//example.com/x"), "https://example.com/x");
4301
+ }
4302
+
4303
+ #[test]
4304
+ fn parse_ddg_lite_extracts_structured_hits() {
4305
+ let hits = parse_ddg_lite(DDG_LITE_SAMPLE);
4306
+ assert_eq!(hits.len(), 2);
4307
+ assert_eq!(hits[0].title, "Rust std docs");
4308
+ assert_eq!(hits[0].url, "https://doc.rust-lang.org/std/");
4309
+ assert_eq!(
4310
+ hits[0].snippet,
4311
+ "The Rust Standard Library & API reference."
4312
+ );
4313
+ assert_eq!(hits[1].title, "crates.io");
4314
+ assert_eq!(hits[1].url, "https://crates.io/");
4315
+ }
4316
+
4317
+ #[test]
4318
+ fn parse_ddg_lite_returns_empty_on_unrelated_html() {
4319
+ assert!(parse_ddg_lite("<html><body>no results here</body></html>").is_empty());
4320
+ }
4321
+
4322
+ #[test]
4323
+ fn format_search_hits_numbers_and_caps() {
4324
+ let hits = parse_ddg_lite(DDG_LITE_SAMPLE);
4325
+ let out = format_search_hits(&hits, 1);
4326
+ assert!(out.starts_with("1. Rust std docs"));
4327
+ assert!(out.contains("https://doc.rust-lang.org/std/"));
4328
+ // Capped at 1 — the second hit must not appear.
4329
+ assert!(!out.contains("crates.io"));
4330
+ }
4331
+
4332
+ // ── docx tables ─────────────────────────────────────────────────────────
4333
+ #[test]
4334
+ fn table_row_and_separator_detection() {
4335
+ assert!(is_table_row("| a | b |"));
4336
+ assert!(is_table_row("|a|b|"));
4337
+ assert!(!is_table_row("a | b")); // no leading pipe
4338
+ assert!(!is_table_row("- bullet"));
4339
+ assert!(is_table_separator("| --- | :---: |"));
4340
+ assert!(!is_table_separator("| a | b |"));
4341
+ }
4342
+
4343
+ #[test]
4344
+ fn parse_table_cells_drops_outer_pipes_and_trims() {
4345
+ assert_eq!(parse_table_cells("| a | b |c|"), vec!["a", "b", "c"]);
4346
+ }
4347
+
4348
+ #[test]
4349
+ fn docx_document_renders_markdown_table() {
4350
+ let body = "Intro line.\n| Name | Price |\n| --- | --- |\n| Glazed | $1.50 |\n| Old Fashioned | $2.00 |\n\nOutro.";
4351
+ let doc = docx_document("Menu", body);
4352
+ // A real table with a header row is emitted.
4353
+ assert!(doc.contains("<w:tbl>"));
4354
+ assert_eq!(doc.matches("<w:tr>").count(), 3); // header + 2 body rows
4355
+ // Header cells are bold; the separator row is gone.
4356
+ assert!(doc.contains("<w:rPr><w:b/></w:rPr><w:t xml:space=\"preserve\">Name</w:t>"));
4357
+ assert!(!doc.contains("---"));
4358
+ // Surrounding prose still renders as paragraphs.
4359
+ assert!(doc.contains(">Intro line.</w:t>"));
4360
+ assert!(doc.contains(">Outro.</w:t>"));
4361
+ }
4362
+
4363
+ #[test]
4364
+ fn docx_document_without_table_is_unchanged_shape() {
4365
+ let doc = docx_document("T", "# Head\n- item\nplain");
4366
+ assert!(!doc.contains("<w:tbl>"));
4367
+ assert!(doc.contains(">• item</w:t>"));
4368
+ }
4369
+
4370
+ // ── docx inline formatting ──────────────────────────────────────────────
4371
+ #[test]
4372
+ fn docx_runs_renders_bold_italic_and_code() {
4373
+ let out = docx_runs("plain **bold** and *italic* and `code` end");
4374
+ // Bold run carries <w:b/>, italic <w:i/>, code the monospace font.
4375
+ assert!(out.contains("<w:rPr><w:b/></w:rPr><w:t xml:space=\"preserve\">bold</w:t>"));
4376
+ assert!(out.contains("<w:rPr><w:i/></w:rPr><w:t xml:space=\"preserve\">italic</w:t>"));
4377
+ assert!(out.contains("w:ascii=\"Consolas\""));
4378
+ assert!(out.contains(">code</w:t>"));
4379
+ // The markers themselves are consumed, not emitted as literal text.
4380
+ assert!(!out.contains('*'));
4381
+ assert!(!out.contains('`'));
4382
+ }
4383
+
4384
+ #[test]
4385
+ fn docx_runs_leaves_arithmetic_and_snake_case_literal() {
4386
+ // Spaced `*` is not emphasis; `_` never toggles italic.
4387
+ let out = docx_runs("compute 5 * 3 for my_var_name");
4388
+ assert!(!out.contains("<w:i/>"));
4389
+ assert!(!out.contains("<w:b/>"));
4390
+ assert!(out.contains("5 * 3 for my_var_name"));
4391
+ }
4392
+
4393
+ #[test]
4394
+ fn docx_runs_code_span_is_verbatim() {
4395
+ // Emphasis markers inside a code span stay literal.
4396
+ let out = docx_runs("call `a*b` now");
4397
+ assert!(out.contains(">a*b</w:t>"));
4398
+ assert!(!out.contains("<w:i/>"));
4399
+ }
4400
+
4401
+ #[test]
4402
+ fn docx_runs_escapes_xml_in_runs() {
4403
+ let out = docx_runs("**a < b & c**");
4404
+ assert!(out.contains("a &lt; b &amp; c"));
4405
+ assert!(out.contains("<w:b/>"));
4406
+ }
4407
+
4408
+ #[test]
4409
+ fn docx_runs_unmatched_marker_stays_literal() {
4410
+ // A lone trailing `**` with nothing after it must not open emphasis.
4411
+ let out = docx_runs("trailing **");
4412
+ assert!(!out.contains("<w:b/>"));
4413
+ assert!(out.contains("trailing **"));
4414
+ }
4415
+
3805
4416
  // ── truncate ────────────────────────────────────────────────────────────
3806
4417
  #[test]
3807
4418
  fn truncate_shorter_than_max_unchanged() {
@@ -4017,7 +4628,6 @@ mod tests {
4017
4628
  "grep_files",
4018
4629
  "list_dir",
4019
4630
  "finish",
4020
- "question",
4021
4631
  "Artifact",
4022
4632
  "python_tool",
4023
4633
  ] {
@@ -4030,6 +4640,7 @@ mod tests {
4030
4640
  "kb_record",
4031
4641
  "verify",
4032
4642
  "text_editor_20250124",
4643
+ "question",
4033
4644
  "AskUserQuestion",
4034
4645
  "read",
4035
4646
  "write",
@@ -4858,6 +5469,53 @@ print("hello " + data.get("name", "world"))
4858
5469
  let _ = fs::remove_dir_all(&d);
4859
5470
  }
4860
5471
 
5472
+ #[test]
5473
+ fn artifact_rejects_local_stylesheet_link() {
5474
+ let d = tempdir();
5475
+ // A 1.5B model's canvas-game stub linked <link rel=stylesheet href=styles.css>;
5476
+ // it must be rejected with a message naming the file and telling the
5477
+ // model to inline the CSS.
5478
+ let body = html_app("draw();").replace(
5479
+ "<head>",
5480
+ "<head><link rel=\"stylesheet\" href=\"styles.css\">",
5481
+ );
5482
+ let r = run(
5483
+ "Artifact",
5484
+ &json!({"title": "Game", "contents": body, "type": "html"}),
5485
+ &d,
5486
+ );
5487
+ assert!(r.is_error);
5488
+ assert!(r.content.contains("styles.css"), "{}", r.content);
5489
+ assert!(r.content.contains("<style"), "{}", r.content);
5490
+ let _ = fs::remove_dir_all(&d);
5491
+ }
5492
+
5493
+ #[test]
5494
+ fn artifact_allows_external_stylesheet_link() {
5495
+ let d = tempdir();
5496
+ let body = html_app("draw();").replace(
5497
+ "<head>",
5498
+ "<head><link rel=\"stylesheet\" href=\"https://cdn.example.com/x.css\">",
5499
+ );
5500
+ let r = run(
5501
+ "Artifact",
5502
+ &json!({"title": "Game", "contents": body, "type": "html"}),
5503
+ &d,
5504
+ );
5505
+ assert!(!r.is_error, "{}", r.content);
5506
+ let _ = fs::remove_dir_all(&d);
5507
+ }
5508
+
5509
+ #[test]
5510
+ fn is_local_asset_classifies_paths() {
5511
+ assert!(is_local_asset("styles.css"));
5512
+ assert!(is_local_asset("./css/app.css"));
5513
+ assert!(!is_local_asset("https://x.com/a.css"));
5514
+ assert!(!is_local_asset("//cdn/a.css"));
5515
+ assert!(!is_local_asset("data:text/css,body{}"));
5516
+ assert!(!is_local_asset(""));
5517
+ }
5518
+
4861
5519
  #[test]
4862
5520
  fn artifact_allows_external_script_src() {
4863
5521
  let d = tempdir();