aura-components-mcp 1.7.2 → 1.7.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.
package/dist/server.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // aura-components-mcp v1.7.2 — bundled (zero deps). Built by swastiksingh-dev.
2
+ // aura-components-mcp v1.7.3 — bundled (zero deps). Built by swastiksingh-dev.
3
3
 
4
4
  // ---- config.mjs ----
5
5
  // Module: config — one small interface (loadConfig) over all env parsing.
@@ -30,7 +30,7 @@ function loadConfig(env = process.env) {
30
30
  codeChars: num(env.AURA_CODE_CHARS, 12_000, { min: 1_000, max: 60_000 }),
31
31
  contentChars: num(env.AURA_CONTENT_CHARS, 12_000, { min: 1_000, max: 60_000 }),
32
32
  freeOnlyDefault: bool(env.AURA_FREE_ONLY_DEFAULT, true),
33
- userAgent: env.AURA_USER_AGENT ?? "aura-components-mcp/1.7.2",
33
+ userAgent: env.AURA_USER_AGENT ?? "aura-components-mcp/1.7.3",
34
34
  };
35
35
  }
36
36
 
@@ -841,7 +841,7 @@ function createCatalog({ fetcher, config, now = () => Date.now(), facetsFn = nul
841
841
  }
842
842
 
843
843
  // ---- protocol.mjs ----
844
- const SERVER_INFO = { name: "aura-components-mcp", version: "1.7.2" };
844
+ const SERVER_INFO = { name: "aura-components-mcp", version: "1.7.3" };
845
845
  // Module: protocol — stdio JSON-RPC framing + MCP handshake + error codes.
846
846
  // One interface: createSession(send) -> { dispatch(msg) }. No business logic.
847
847
 
@@ -1690,7 +1690,7 @@ function createVersion({ fetcher, running, now = () => Date.now() }) {
1690
1690
  const AWARD_SCHEMA_VERSION = 1;
1691
1691
  const AWARD_BASE_URL = "https://www.awwwards.com";
1692
1692
  const AWARD_ASSETS_URL = "https://assets.awwwards.com";
1693
- const AWARD_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36 aura-components-mcp/1.7.2";
1693
+ const AWARD_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36 aura-components-mcp/1.7.3";
1694
1694
  const AWARD_ALLOWED_HOSTS = ["www.awwwards.com", "awwwards.com", "assets.awwwards.com"];
1695
1695
  const AWARD_TIMEOUT_MS = 10000;
1696
1696
  const AWARD_MAX_BYTES = 2 * 1024 * 1024;
@@ -1720,13 +1720,152 @@ function awardNormalizeSlug(s) {
1720
1720
  if (!v || v.length > 120) throw awardBad("slug must be 1-120 chars of [a-z0-9-], got: " + String(s).slice(0, 80));
1721
1721
  return v;
1722
1722
  }
1723
+ // Watches of kind "url" target a site slug. Accept either a bare slug or a
1724
+ // canonical Awwwards site URL and reduce both to the same slug, so a stored
1725
+ // watch always compares equal to the indexed slug (legacy raw-URL records
1726
+ // are normalized at match time too).
1727
+ function awardWatchTargetSlug(raw) {
1728
+ const s = String(raw ?? "").trim();
1729
+ if (!s) throw awardBad("url watch needs a site slug or an awwwards /sites/<slug> url");
1730
+ let slug = s;
1731
+ if (/^https?:\/\//i.test(s)) {
1732
+ let u;
1733
+ try { u = new URL(s); } catch { throw awardBad("url watch is not a parseable url: " + s.slice(0, 120)); }
1734
+ const host = u.hostname.toLowerCase();
1735
+ if (!AWARD_ALLOWED_HOSTS.includes(host)) throw awardBad("url watch must point at awwwards.com, got: " + host);
1736
+ const m = /^\/sites\/([^/]+)\/?$/.exec(u.pathname);
1737
+ if (!m) throw awardBad("url watch must be an awwwards.com/sites/<slug> url, got path: " + u.pathname.slice(0, 120));
1738
+ slug = m[1];
1739
+ }
1740
+ return awardNormalizeSlug(slug);
1741
+ }
1723
1742
 
1724
1743
  // ---- URL safety (SSRF guard) ----
1725
- const AWARD_BLOCKED_HOST_RES = [/^localhost$/i, /^127\./, /^10\./, /^192\.168\./, /^172\.(1[6-9]|2\d|3[01])\./, /^169\.254\./, /^0\.0\.0\.0$/, /^\[?::1\]?$/, /\.local$/i, /^metadata\.google\.internal$/i];
1744
+ // Parsed-address classification, not regex matching: WHATWG URL already
1745
+ // canonicalizes alternate IPv4 spellings (0x7f000001 → 127.0.0.1, 2130706433
1746
+ // → 127.0.0.1) and IPv4-mapped IPv6 (::ffff:127.0.0.1 → ::ffff:7f00:1), so
1747
+ // policy decisions run on normalized forms. allowLocal relaxes LOOPBACK ONLY
1748
+ // (127.0.0.0/8, ::1, localhost names) for intended local dev builds; private
1749
+ // ranges, link-local, CGNAT, ULA, multicast/reserved and cloud metadata stay
1750
+ // blocked regardless of allowLocal. See SECURITY.md.
1751
+ const AWARD_METADATA_HOSTNAMES = new Set(["metadata.google.internal", "metadata.goog", "instance-data", "instance-data.ec2.internal"]);
1752
+ const AWARD_LOCAL_HOST_SUFFIXES = [".local", ".internal", ".home.arpa"];
1753
+ function awardIpv4Number(a, b, c, d) { return (((a << 24) >>> 0) + (b << 16) + (c << 8) + d) >>> 0; }
1754
+ function awardInIpv4Range(n, base, bits) {
1755
+ const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
1756
+ return ((n & mask) >>> 0) === ((base & mask) >>> 0);
1757
+ }
1758
+ function awardParseIpv4(s) {
1759
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(String(s || ""));
1760
+ if (!m) return null;
1761
+ const parts = m.slice(1).map(Number);
1762
+ if (parts.some((p) => p > 255)) return null;
1763
+ return awardIpv4Number(parts[0], parts[1], parts[2], parts[3]);
1764
+ }
1765
+ function awardClassifyIpv4(n) {
1766
+ if (awardInIpv4Range(n, 0x7f000000, 8)) return "loopback";
1767
+ if (awardInIpv4Range(n, 0x0a000000, 8)) return "private";
1768
+ if (awardInIpv4Range(n, 0xac100000, 12)) return "private";
1769
+ if (awardInIpv4Range(n, 0xc0a80000, 16)) return "private";
1770
+ if (awardInIpv4Range(n, 0x64400000, 10)) return "private"; // 100.64.0.0/10 CGNAT (incl. Alibaba metadata)
1771
+ if (awardInIpv4Range(n, 0xa9fe0000, 16)) return "link-local"; // 169.254/16 (incl. AWS/GCP/Azure IMDS)
1772
+ if (n === 0) return "unspecified";
1773
+ if (awardInIpv4Range(n, 0xe0000000, 4)) return "multicast";
1774
+ if (awardInIpv4Range(n, 0xf0000000, 4)) return "reserved"; // 240/4 incl. broadcast
1775
+ if (awardInIpv4Range(n, 0xc0000000, 24)) return "reserved"; // 192.0.0.0/24 (incl. Oracle metadata .192)
1776
+ if (awardInIpv4Range(n, 0xc0000200, 24)) return "reserved"; // 192.0.2.0/24 TEST-NET-1
1777
+ if (awardInIpv4Range(n, 0xc6120000, 15)) return "reserved"; // 198.18.0.0/15 benchmark
1778
+ if (awardInIpv4Range(n, 0xc6336400, 24)) return "reserved"; // 198.51.100.0/24 TEST-NET-2
1779
+ if (awardInIpv4Range(n, 0xcb007100, 24)) return "reserved"; // 203.0.113.0/24 TEST-NET-3
1780
+ return "public";
1781
+ }
1782
+ function awardParseIpv6(s) {
1783
+ let t = String(s || "").toLowerCase();
1784
+ if (t.startsWith("[") && t.endsWith("]")) t = t.slice(1, -1);
1785
+ if (!t.includes(":")) return null;
1786
+ let head = t, tail = null;
1787
+ const dc = t.indexOf("::");
1788
+ if (dc >= 0) { head = t.slice(0, dc); tail = t.slice(dc + 2); }
1789
+ const groups = (part) => (part ? part.split(":") : []);
1790
+ const headG = groups(head), tailG = groups(tail);
1791
+ const bytes = [];
1792
+ const pushGroup = (g) => {
1793
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(g)) {
1794
+ const n = awardParseIpv4(g);
1795
+ if (n === null) return false;
1796
+ bytes.push((n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255);
1797
+ return true;
1798
+ }
1799
+ if (!/^[0-9a-f]{1,4}$/.test(g)) return false;
1800
+ const v = parseInt(g, 16);
1801
+ bytes.push((v >> 8) & 255, v & 255);
1802
+ return true;
1803
+ };
1804
+ for (const g of headG) if (!pushGroup(g)) return null;
1805
+ if (tail !== null) {
1806
+ const left = 16 - bytes.length;
1807
+ if (left < 0) return null;
1808
+ const tailBytes = [];
1809
+ for (const g of tailG) {
1810
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(g)) {
1811
+ const n = awardParseIpv4(g);
1812
+ if (n === null) return null;
1813
+ tailBytes.push((n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255);
1814
+ } else {
1815
+ if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
1816
+ const v = parseInt(g, 16);
1817
+ tailBytes.push((v >> 8) & 255, v & 255);
1818
+ }
1819
+ if (bytes.length + tailBytes.length > 16) return null;
1820
+ }
1821
+ const voidCount = 16 - bytes.length - tailBytes.length;
1822
+ if (voidCount < 0) return null;
1823
+ for (let i = 0; i < voidCount; i++) bytes.push(0);
1824
+ bytes.push(...tailBytes);
1825
+ }
1826
+ if (bytes.length !== 16) return null;
1827
+ return bytes;
1828
+ }
1829
+ function awardClassifyIpv6(bytes) {
1830
+ const allZero = bytes.every((b) => b === 0);
1831
+ if (allZero) return "unspecified";
1832
+ if (bytes.slice(0, 15).every((b) => b === 0) && bytes[15] === 1) return "loopback";
1833
+ const first10 = bytes.slice(0, 10).every((b) => b === 0);
1834
+ const mapped = first10 && bytes[10] === 0xff && bytes[11] === 0xff; // ::ffff:0:0/96
1835
+ const compatible = first10 && bytes[10] === 0 && bytes[11] === 0 && bytes.slice(12, 15).some((b) => b !== 0); // deprecated ::a.b.c.d
1836
+ if (mapped || compatible) {
1837
+ const v4 = awardClassifyIpv4(awardIpv4Number(bytes[12], bytes[13], bytes[14], bytes[15]));
1838
+ return mapped ? v4 : (v4 === "public" ? "reserved" : v4);
1839
+ }
1840
+ // NAT64 well-known prefix 64:ff9b::/96 embeds an IPv4 destination.
1841
+ if (bytes[0] === 0x00 && bytes[1] === 0x64 && bytes[2] === 0xff && bytes[3] === 0x9b && bytes.slice(4, 12).every((b) => b === 0)) {
1842
+ return awardClassifyIpv4(awardIpv4Number(bytes[12], bytes[13], bytes[14], bytes[15]));
1843
+ }
1844
+ if ((bytes[0] & 0xfe) === 0xfc) return "private"; // fc00::/7 unique-local
1845
+ if (bytes[0] === 0xfe && (bytes[1] & 0xc0) === 0x80) return "link-local"; // fe80::/10
1846
+ if (bytes[0] === 0xff) return "multicast";
1847
+ if (bytes[0] === 0x20 && bytes[1] === 0x01 && bytes[2] === 0x0d && bytes[3] === 0xb8) return "reserved"; // 2001:db8::/32 documentation
1848
+ return "public";
1849
+ }
1850
+ function awardClassifyHost(hostname) {
1851
+ let h = String(hostname ?? "").toLowerCase().replace(/\.+$/, "");
1852
+ if (!h) return { kind: "invalid", host: String(hostname ?? "") };
1853
+ if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
1854
+ if (h.includes(":")) {
1855
+ const bytes = awardParseIpv6(h);
1856
+ if (!bytes) return { kind: "invalid", host: h };
1857
+ return { kind: awardClassifyIpv6(bytes), version: 6, host: h };
1858
+ }
1859
+ const v4 = awardParseIpv4(h);
1860
+ if (v4 !== null) return { kind: awardClassifyIpv4(v4), version: 4, host: h };
1861
+ if (/^[0-9.]+$/.test(h)) return { kind: "invalid", host: h }; // numeric-looking but unparseable
1862
+ if (h === "localhost" || h.endsWith(".localhost")) return { kind: "loopback", version: null, host: h };
1863
+ if (AWARD_METADATA_HOSTNAMES.has(h)) return { kind: "metadata", version: null, host: h };
1864
+ if (AWARD_LOCAL_HOST_SUFFIXES.some((suf) => h.endsWith(suf))) return { kind: "local-name", version: null, host: h };
1865
+ return { kind: "public", version: null, host: h };
1866
+ }
1726
1867
  function awardIsBlockedHost(hostname) {
1727
- const h = String(hostname || "").toLowerCase().replace(/\.$/, "");
1728
- for (const re of AWARD_BLOCKED_HOST_RES) if (re.test(h)) return true;
1729
- return false;
1868
+ return awardClassifyHost(hostname).kind !== "public";
1730
1869
  }
1731
1870
  function awardValidateHttpUrl(raw, { allowLocal = false } = {}) {
1732
1871
  if (typeof raw !== "string" || !raw.trim()) throw awardBad("url must be a non-empty http(s) string");
@@ -1735,17 +1874,67 @@ function awardValidateHttpUrl(raw, { allowLocal = false } = {}) {
1735
1874
  try { u = new URL(raw.trim()); } catch { throw awardBad("url is not parseable: " + raw.slice(0, 120)); }
1736
1875
  if (u.protocol !== "http:" && u.protocol !== "https:") throw awardBad("only http/https urls are allowed, got: " + u.protocol);
1737
1876
  if (u.username || u.password) throw awardBad("urls with credentials are not allowed");
1738
- if (awardIsBlockedHost(u.hostname) && !allowLocal) {
1739
- const e = new Error("private/local target blocked (pass allowLocal:true for loopback dev builds): " + u.hostname);
1740
- e.code = "UNSUPPORTED_OPERATION";
1741
- throw e;
1742
- }
1743
- if (u.hostname === "169.254.169.254" || /metadata/i.test(u.hostname)) {
1744
- const e = new Error("cloud metadata endpoints are never fetched");
1745
- e.code = "UNSUPPORTED_OPERATION";
1746
- throw e;
1877
+ const cls = awardClassifyHost(u.hostname);
1878
+ if (cls.kind === "public") return u.toString();
1879
+ if (allowLocal && cls.kind === "loopback") return u.toString();
1880
+ const e = new Error(awardBlockedReason(cls, u.hostname));
1881
+ e.code = "UNSUPPORTED_OPERATION";
1882
+ e.hostKind = cls.kind;
1883
+ throw e;
1884
+ }
1885
+ function awardBlockedReason(cls, hostname) {
1886
+ const h = String(hostname);
1887
+ if (cls.kind === "loopback") return "loopback target blocked (pass allowLocal:true for intended local dev builds): " + h;
1888
+ if (cls.kind === "metadata") return "cloud metadata endpoints are never fetched: " + h;
1889
+ if (cls.kind === "private") return "private-network targets are never fetched, even with allowLocal:true (allowLocal permits loopback only): " + h;
1890
+ if (cls.kind === "link-local") return "link-local targets are never fetched (" + h + ")";
1891
+ if (cls.kind === "unspecified" || cls.kind === "multicast" || cls.kind === "reserved") return "non-routable target blocked (" + cls.kind + "): " + h;
1892
+ if (cls.kind === "local-name") return "local-network hostname blocked (" + h + "); only loopback dev targets are permitted with allowLocal:true";
1893
+ return "unrecognized host is not fetched: " + h;
1894
+ }
1895
+
1896
+ // ---- DNS resolution guard ----
1897
+ // Best-effort and positive-blocking only: a hostname that RESOLVES to a
1898
+ // prohibited address is refused; a lookup that fails (offline DNS) is not an
1899
+ // error here — the real connection decides, and every redirect hop is
1900
+ // re-validated anyway. Bounded: lookups that take too long return unchecked
1901
+ // instead of stalling a tool call.
1902
+ async function awardCheckHostResolution(rawUrl, { lookup = null, allowLocal = false, timeoutMs = 1500 } = {}) {
1903
+ const out = { url: String(rawUrl), host: null, checked: false, blocked: false, addresses: [], blockedAddresses: [], reason: null };
1904
+ let u;
1905
+ try { u = new URL(String(rawUrl)); } catch { out.reason = "unparseable url"; return out; }
1906
+ const cls = awardClassifyHost(u.hostname);
1907
+ out.host = cls.host;
1908
+ if (cls.version !== null) { out.checked = true; return out; } // IP literals were classified at validation time
1909
+ const fn = lookup || awardDefaultLookup();
1910
+ if (typeof fn !== "function") { out.reason = "no dns lookup available"; return out; }
1911
+ let addrs;
1912
+ let timer = null;
1913
+ try {
1914
+ addrs = await Promise.race([
1915
+ Promise.resolve(fn(cls.host, { all: true })),
1916
+ new Promise((resolve) => { timer = setTimeout(() => resolve(null), timeoutMs); }),
1917
+ ]);
1918
+ } catch (e) { out.reason = "dns lookup failed: " + String((e && e.message) || e).slice(0, 120); return out; }
1919
+ finally { if (timer) clearTimeout(timer); }
1920
+ if (addrs === null || addrs === undefined) { out.reason = "dns lookup timed out"; return out; }
1921
+ const list = Array.isArray(addrs) ? addrs : [addrs];
1922
+ out.checked = true;
1923
+ for (const a of list) {
1924
+ const address = typeof a === "string" ? a : (a && a.address) || "";
1925
+ if (!address) continue;
1926
+ out.addresses.push(address);
1927
+ const k = awardClassifyHost(address).kind;
1928
+ const loopbackOk = allowLocal && k === "loopback";
1929
+ if (k !== "public" && !loopbackOk) out.blockedAddresses.push(address);
1747
1930
  }
1748
- return u.toString();
1931
+ out.blocked = out.blockedAddresses.length > 0;
1932
+ return out;
1933
+ }
1934
+ function awardDefaultLookup() {
1935
+ const g = globalThis.process && globalThis.process.getBuiltinModule ? globalThis.process.getBuiltinModule : null;
1936
+ if (typeof g !== "function") return null;
1937
+ try { return g.call(globalThis.process, "node:dns").promises.lookup; } catch { return null; }
1749
1938
  }
1750
1939
  function awardValidateAwardPath(pathOrUrl) {
1751
1940
  const s = String(pathOrUrl ?? "");
@@ -1787,39 +1976,47 @@ function awardElementUrl(mediaPath) { return AWARD_ASSETS_URL + "/awards/" + Str
1787
1976
 
1788
1977
  // ---- Parsers (scoped, fixture-driven, resilient to missing fields) ----
1789
1978
  const AWARD_LABELS = { sotd: "Site of the Day", dev: "Developer Award", hm: "Honorable Mention", sotm: "Site of the Month", mobile: "Mobile Excellence", ecom: "E-Commerce Award" };
1790
- function awardParseListing(html) {
1979
+ // Bump when the listing parser's expectations about upstream markup change;
1980
+ // diagnostics carry it so callers can tell "page is empty" from "parser can
1981
+ // no longer read the page" without guessing.
1982
+ const AWARD_PARSER_VERSION = 1;
1983
+ const AWARD_FIELD_BOUNDS = { slug: 120, title: 200, tag: 40, tags: 20, studio: 120, liveUrl: 500, thumb: 500 };
1984
+ function awardBoundStr(v, max) { return String(v ?? "").slice(0, max); }
1985
+ function awardParseListingEx(html) {
1791
1986
  const src = String(html || "");
1792
- if (!src) return [];
1987
+ const diagnostics = { parserVersion: AWARD_PARSER_VERSION, bytes: src.length, blobs: 0, parsed: 0, filtered: 0, malformed: 0, anchors: 0, verdict: "empty-input" };
1988
+ if (!src) return { sites: [], diagnostics };
1793
1989
  const sites = [];
1794
1990
  const seen = new Set();
1795
1991
  const parts = src.split('data-collectable-model-value="');
1796
1992
  const blobParts = parts.slice(1);
1993
+ diagnostics.blobs = blobParts.length;
1797
1994
  for (const part of blobParts) {
1798
1995
  const end = part.indexOf('">');
1799
- if (end < 0 || end > 20000) continue;
1996
+ if (end < 0 || end > 20000) { diagnostics.malformed++; continue; }
1800
1997
  let meta;
1801
- try { meta = JSON.parse(awardDecodeEntities(part.slice(0, end))); } catch { continue; }
1802
- if (!meta || !meta.slug || !meta.title) continue;
1803
- if (meta.type && meta.type !== "submission") continue;
1804
- const slug = String(meta.slug).toLowerCase();
1805
- if (seen.has(slug)) continue;
1998
+ try { meta = JSON.parse(awardDecodeEntities(part.slice(0, end))); } catch { diagnostics.malformed++; continue; }
1999
+ if (!meta || !meta.slug || !meta.title) { diagnostics.filtered++; continue; }
2000
+ if (meta.type && meta.type !== "submission") { diagnostics.filtered++; continue; }
2001
+ const slug = awardBoundStr(String(meta.slug).toLowerCase(), AWARD_FIELD_BOUNDS.slug);
2002
+ if (seen.has(slug)) { diagnostics.filtered++; continue; }
1806
2003
  const card = part.slice(end, end + 8000);
1807
2004
  const detailMatch = card.match(/href="(\/sites\/[^"#?]+)"/);
1808
- if (!detailMatch) continue;
2005
+ if (!detailMatch) { diagnostics.filtered++; continue; }
1809
2006
  const liveMatch = card.match(/class="figure-rollover__bt"[^>]*href="(https?:\/\/[^"]+)"/);
1810
2007
  const awards = [...new Set([...card.matchAll(/budget-tag--([a-z-]+)/g)].map((m) => AWARD_LABELS[m[1]] ?? m[1]))];
1811
2008
  const studioMatch = card.match(/avatar-name__title">([^<]+)</);
1812
2009
  seen.add(slug);
1813
2010
  sites.push({
1814
2011
  slug,
1815
- title: awardDecodeEntities(meta.title).trim(),
2012
+ title: awardBoundStr(awardDecodeEntities(meta.title).trim(), AWARD_FIELD_BOUNDS.title),
1816
2013
  createdAt: Number(meta.createdAt) || 0,
1817
- tags: Array.isArray(meta.tags) ? meta.tags.map((t) => awardDecodeEntities(t)).filter(Boolean) : [],
1818
- thumbnailPath: (meta.images && meta.images.thumbnail) ? String(meta.images.thumbnail) : "",
1819
- liveUrl: liveMatch ? awardDecodeEntities(liveMatch[1]) : null,
2014
+ tags: Array.isArray(meta.tags) ? meta.tags.map((t) => awardBoundStr(awardDecodeEntities(t), AWARD_FIELD_BOUNDS.tag)).filter(Boolean).slice(0, AWARD_FIELD_BOUNDS.tags) : [],
2015
+ thumbnailPath: (meta.images && meta.images.thumbnail) ? awardBoundStr(meta.images.thumbnail, AWARD_FIELD_BOUNDS.thumb) : "",
2016
+ liveUrl: liveMatch ? awardBoundStr(awardDecodeEntities(liveMatch[1]), AWARD_FIELD_BOUNDS.liveUrl) : null,
1820
2017
  detailPath: detailMatch[1],
1821
2018
  awards,
1822
- studio: studioMatch ? awardDecodeEntities(studioMatch[1]).trim() : null,
2019
+ studio: studioMatch ? awardBoundStr(awardDecodeEntities(studioMatch[1]).trim(), AWARD_FIELD_BOUNDS.studio) : null,
1823
2020
  source: "awwwards",
1824
2021
  sourceUrl: AWARD_BASE_URL + detailMatch[1],
1825
2022
  });
@@ -1836,8 +2033,22 @@ function awardParseListing(html) {
1836
2033
  sites.push({ slug, title: awardStripTags(m[2]) || slug, createdAt: 0, tags: [], thumbnailPath: "", liveUrl: null, detailPath: "/sites/" + slug, awards: [], studio: null, source: "awwwards", sourceUrl: AWARD_BASE_URL + "/sites/" + slug });
1837
2034
  if (sites.length >= 50) break;
1838
2035
  }
2036
+ diagnostics.anchors = sites.length;
1839
2037
  }
1840
- return sites;
2038
+ diagnostics.parsed = sites.length;
2039
+ // Verdict: "ok" whenever cards were read. With zero cards, distinguish a
2040
+ // page the parser understood (filtered/empty listing) from markup it can
2041
+ // no longer read (drift) — callers can keep trust on the former and warn
2042
+ // or abort on the latter instead of treating 0 as an error everywhere.
2043
+ if (sites.length) diagnostics.verdict = "ok";
2044
+ else if (diagnostics.malformed > 0) diagnostics.verdict = "drift-empty";
2045
+ else if (blobParts.length > 0) diagnostics.verdict = "filtered-empty";
2046
+ else if (/awwwards|data-collectable|\/sites\//i.test(src)) diagnostics.verdict = "empty-listing";
2047
+ else diagnostics.verdict = "drift-empty";
2048
+ return { sites, diagnostics };
2049
+ }
2050
+ function awardParseListing(html) {
2051
+ return awardParseListingEx(html).sites;
1841
2052
  }
1842
2053
  function awardIsNomineePage(html) { return /<h2 class="text-default">\s*Nominee\b/.test(String(html || "")); }
1843
2054
  function awardParseScore(html) {
@@ -2091,6 +2302,45 @@ function awardSiteIdeas(detail) {
2091
2302
  return ideas.slice(0, 6);
2092
2303
  }
2093
2304
 
2305
+ // ---- Bounded body reader ----
2306
+ // Reads at most maxBytes from a fetch Response without trusting
2307
+ // content-length: a streaming read stops as soon as the threshold is
2308
+ // crossed, so a missing or lying header cannot balloon memory. Falls back
2309
+ // to res.text() (with a size post-check) when the response exposes no byte
2310
+ // stream, which keeps plain test doubles working.
2311
+ async function awardReadBoundedBody(res, maxBytes, label = "response") {
2312
+ if (!res) throw awardUpstream(label + " has no response body");
2313
+ const len = res.headers && typeof res.headers.get === "function" ? Number(res.headers.get("content-length")) : NaN;
2314
+ if (Number.isFinite(len) && len > maxBytes) throw awardUpstream(label + " exceeds " + maxBytes + " bytes");
2315
+ const body = res.body;
2316
+ if (body && typeof body.getReader === "function") {
2317
+ const reader = body.getReader();
2318
+ const chunks = [];
2319
+ let total = 0;
2320
+ let oversize = false;
2321
+ try {
2322
+ for (;;) {
2323
+ const step = await reader.read();
2324
+ if (!step || step.done) break;
2325
+ const value = step.value;
2326
+ if (value === undefined || value === null) continue;
2327
+ total += typeof value === "string" ? value.length : (value.byteLength || 0);
2328
+ if (total > maxBytes) { oversize = true; throw awardUpstream(label + " exceeds " + maxBytes + " bytes"); }
2329
+ chunks.push(value);
2330
+ }
2331
+ } finally {
2332
+ if (oversize) { try { await reader.cancel(); } catch { /* stream already errored */ } }
2333
+ }
2334
+ const decoder = new TextDecoder("utf-8");
2335
+ let text = "";
2336
+ for (const c of chunks) text += typeof c === "string" ? c : decoder.decode(c, { stream: true });
2337
+ return text + decoder.decode();
2338
+ }
2339
+ const text = await res.text();
2340
+ if (typeof text === "string" && text.length > maxBytes) throw awardUpstream(label + " exceeds " + maxBytes + " bytes");
2341
+ return text || "";
2342
+ }
2343
+
2094
2344
  // ---- Polite fetch client (1 req/s, bounded, distinct errors) ----
2095
2345
  function createAwardClient({ fetchImpl, timeoutMs = AWARD_TIMEOUT_MS, rateMs = AWARD_RATE_MS, userAgent = AWARD_USER_AGENT } = {}) {
2096
2346
  const fetchFn = fetchImpl || globalThis.fetch;
@@ -2107,63 +2357,87 @@ function createAwardClient({ fetchImpl, timeoutMs = AWARD_TIMEOUT_MS, rateMs = A
2107
2357
  return next;
2108
2358
  };
2109
2359
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2110
- async function fetchOnce(url, label) {
2111
- const ctrl = new AbortController();
2112
- const timer = setTimeout(() => ctrl.abort(new Error("timeout after " + timeoutMs + "ms fetching " + label)), timeoutMs);
2113
- try {
2114
- const res = await fetchFn(url, { headers: { "user-agent": userAgent, accept: "text/html,*/*" }, redirect: "manual", signal: ctrl.signal });
2115
- return res;
2116
- } finally { clearTimeout(timer); }
2117
- }
2118
- async function readBounded(res, label) {
2119
- const len = res.headers && typeof res.headers.get === "function" ? Number(res.headers.get("content-length")) : NaN;
2120
- if (Number.isFinite(len) && len > AWARD_MAX_BYTES) throw awardUpstream(label + " exceeds " + AWARD_MAX_BYTES + " bytes");
2121
- const text = await res.text();
2122
- if (text && text.length > AWARD_MAX_BYTES) throw awardUpstream(label + " exceeds " + AWARD_MAX_BYTES + " bytes");
2123
- return text || "";
2124
- }
2360
+ const isRedirect = (status) => status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
2125
2361
  async function getAwardHtml(pathOrUrl) {
2126
2362
  const path = awardValidateAwardPath(pathOrUrl);
2127
2363
  const startUrl = AWARD_BASE_URL + path;
2128
2364
  let url = startUrl;
2129
- let lastErr = null;
2130
- for (let attempt = 0; attempt < 2; attempt++) {
2365
+ let retried = false; // single retry shared by 5xx and network failures
2366
+ let redirects = 0; // redirects do not consume the retry budget
2367
+ for (;;) {
2131
2368
  await acquire();
2132
- let res;
2133
- try { res = await fetchOnce(url, "awwwards page"); }
2134
- catch (e) {
2135
- if (e && e.name === "AbortError") throw awardUpstream("timeout fetching " + url);
2136
- lastErr = e;
2137
- if (attempt < 1) { await sleep(300); continue; }
2138
- throw awardUpstream("network error fetching " + url + ": " + String((e && e.message) || e).slice(0, 200));
2139
- }
2140
- if (res.status === 301 || res.status === 302 || res.status === 303 || res.status === 307 || res.status === 308) {
2141
- const loc = res.headers && res.headers.get ? res.headers.get("location") : null;
2142
- if (!loc || attempt === 1) throw awardUpstream("redirect without location for " + url);
2369
+ const ctrl = new AbortController();
2370
+ let timedOut = false;
2371
+ // One deadline covers connect AND body read: a stalled stream aborts too.
2372
+ const timer = setTimeout(() => { timedOut = true; ctrl.abort(); }, timeoutMs);
2373
+ let res = null;
2374
+ let outcome = null;
2375
+ try {
2143
2376
  try {
2144
- const next = new URL(loc, url).toString();
2145
- const nu = new URL(next);
2146
- if (!AWARD_ALLOWED_HOSTS.includes(nu.hostname.toLowerCase())) throw awardUpstream("redirect leaves award hosts: " + nu.hostname);
2147
- url = next;
2148
- continue;
2149
- } catch (e) { if (e && e.code === "UPSTREAM_ERROR") throw e; throw awardUpstream("bad redirect for " + url); }
2150
- }
2151
- if (res.status === 404) throw awardNotFound("awwwards page not found: " + url);
2152
- if (res.status === 403 || res.status === 429) {
2153
- const e = awardUpstream("awwwards is blocking requests (HTTP " + res.status + " on " + url + "). Try again later; blocks are never retried.");
2154
- e.blocked = true;
2155
- e.status = res.status;
2156
- throw e;
2157
- }
2158
- if (res.status >= 500 && res.status < 600) {
2159
- lastErr = awardUpstream("transient awwwards status " + res.status + " on " + url);
2160
- if (attempt < 1) { await sleep(400); continue; }
2161
- throw lastErr;
2162
- }
2163
- if (!res.ok) throw awardUpstream("awwwards HTTP " + res.status + " on " + url);
2164
- return { html: await readBounded(res, "awwwards page"), finalUrl: url };
2377
+ res = await fetchFn(url, { headers: { "user-agent": userAgent, accept: "text/html,*/*" }, redirect: "manual", signal: ctrl.signal });
2378
+ } catch (e) {
2379
+ outcome = { kind: timedOut ? "timeout" : "network", error: e };
2380
+ }
2381
+ if (!outcome) {
2382
+ const status = res.status;
2383
+ if (isRedirect(status)) {
2384
+ const loc = res.headers && typeof res.headers.get === "function" ? res.headers.get("location") : null;
2385
+ redirects += 1;
2386
+ if (!loc) outcome = { kind: "throw", error: awardUpstream("redirect without location for " + url) };
2387
+ else if (redirects > 5) outcome = { kind: "throw", error: awardUpstream("too many redirects fetching " + startUrl) };
2388
+ else {
2389
+ // Per-hop re-validation: fresh SSRF classification + host allow-list.
2390
+ try {
2391
+ const next = awardValidateHttpUrl(new URL(loc, url).toString());
2392
+ const host = new URL(next).hostname.toLowerCase();
2393
+ if (!AWARD_ALLOWED_HOSTS.includes(host)) throw awardUpstream("redirect leaves award hosts: " + host);
2394
+ url = next;
2395
+ outcome = { kind: "redirect" };
2396
+ } catch (e) {
2397
+ outcome = { kind: "throw", error: e && e.code ? e : awardUpstream("bad redirect for " + url) };
2398
+ }
2399
+ }
2400
+ } else if (status === 404) {
2401
+ outcome = { kind: "throw", error: awardNotFound("awwwards page not found: " + url) };
2402
+ } else if (status === 403 || status === 429) {
2403
+ const e = awardUpstream("awwwards is blocking requests (HTTP " + status + " on " + url + "). Try again later; blocks are never retried.");
2404
+ e.blocked = true;
2405
+ e.status = status;
2406
+ outcome = { kind: "throw", error: e };
2407
+ } else if (status >= 500 && status < 600) {
2408
+ outcome = retried
2409
+ ? { kind: "throw", error: awardUpstream("transient awwwards status " + status + " on " + url) }
2410
+ : { kind: "retry" };
2411
+ } else if (!res.ok) {
2412
+ outcome = { kind: "throw", error: awardUpstream("awwwards HTTP " + status + " on " + url) };
2413
+ } else {
2414
+ try {
2415
+ const html = await awardReadBoundedBody(res, AWARD_MAX_BYTES, "awwwards page");
2416
+ outcome = { kind: "html", html };
2417
+ res = null; // body fully consumed — nothing left to drain
2418
+ } catch (e) {
2419
+ outcome = { kind: "throw", error: e && e.code ? e : awardUpstream((timedOut ? "timeout fetching " : "awwwards body read failed for ") + url) };
2420
+ }
2421
+ }
2422
+ }
2423
+ } finally {
2424
+ clearTimeout(timer);
2425
+ if (res && !res.bodyUsed) { try { if (res.body && typeof res.body.cancel === "function") await res.body.cancel(); } catch { /* socket already released */ } }
2426
+ }
2427
+ if (outcome.kind === "html") return { html: outcome.html, finalUrl: url };
2428
+ if (outcome.kind === "redirect") continue;
2429
+ if (timedOut) throw awardUpstream("timeout fetching " + url);
2430
+ if (outcome.kind === "retry") {
2431
+ retried = true;
2432
+ await sleep(400);
2433
+ continue;
2434
+ }
2435
+ if (outcome.kind === "network") {
2436
+ if (!retried) { retried = true; await sleep(300); continue; }
2437
+ throw awardUpstream("network error fetching " + url + ": " + String((outcome.error && outcome.error.message) || outcome.error).slice(0, 200));
2438
+ }
2439
+ throw outcome.error;
2165
2440
  }
2166
- throw lastErr instanceof Error ? lastErr : awardUpstream("fetch failed for " + startUrl);
2167
2441
  }
2168
2442
  return { getAwardHtml };
2169
2443
  }
@@ -2196,30 +2470,80 @@ const AWARD_STATIC_LIBS = [
2196
2470
  { lib: "barba", res: [/barba/i] },
2197
2471
  ];
2198
2472
  const AWARD_STATIC_CUES = ["data-aos", "data-scroll", "scrolltrigger", "sticky", "parallax", "marquee", "reveal", "loader", "preloader"];
2199
- async function awardStaticMotion(rawUrl, { fetchImpl, timeoutMs = AWARD_TIMEOUT_MS, maxBytes = 1500000, allowLocal = false } = {}) {
2473
+ // ---- Guarded HTML fetch (shared by static motion + audit static fallback) ----
2474
+ // Manual redirects (≤3 hops) with per-hop URL classification and a
2475
+ // best-effort DNS guard, one deadline per hop covering the body read, and a
2476
+ // streaming bounded read. Returns {ok:true, url, finalUrl, html} or
2477
+ // {ok:false, reason, status?}; only a contract violation (empty input)
2478
+ // throws.
2479
+ async function awardFetchGuardedHtml(rawUrl, { fetchImpl, lookup = null, timeoutMs = AWARD_TIMEOUT_MS, maxBytes = 1500000, allowLocal = false, label = "page" } = {}) {
2200
2480
  if (typeof rawUrl !== "string" || !rawUrl.trim()) throw awardErr("BAD_ARGS", "url must be a non-empty string");
2201
- const fail = (reason) => ({ status: "unavailable", url: String(rawUrl).slice(0, 200), reason: String(reason).slice(0, 200) });
2202
2481
  let target;
2203
2482
  try { target = awardValidateHttpUrl(rawUrl, { allowLocal }); }
2204
- catch (e) { return fail((e && e.message) || e); }
2483
+ catch (e) { return { ok: false, reason: (e && e.message) || String(e) }; }
2205
2484
  const fetchFn = fetchImpl || globalThis.fetch;
2206
- let html;
2207
- try {
2485
+ if (typeof fetchFn !== "function") return { ok: false, reason: "no fetch available" };
2486
+ const guard = async (u) => {
2487
+ try {
2488
+ const dns = await awardCheckHostResolution(u, { lookup, allowLocal });
2489
+ return dns.blocked ? "host resolves to a blocked address (" + dns.blockedAddresses.join(", ") + ")" : null;
2490
+ } catch { return null; }
2491
+ };
2492
+ const firstBlock = await guard(target);
2493
+ if (firstBlock) return { ok: false, reason: firstBlock };
2494
+ let url = target;
2495
+ for (let hop = 0; hop <= 3; hop++) {
2208
2496
  const ctrl = new AbortController();
2209
- const timer = setTimeout(() => ctrl.abort(), timeoutMs);
2497
+ let timedOut = false;
2498
+ const timer = setTimeout(() => { timedOut = true; ctrl.abort(); }, timeoutMs);
2499
+ let res = null;
2210
2500
  try {
2211
- const res = await fetchFn(target, { headers: { "user-agent": AWARD_USER_AGENT, accept: "text/html,*/*" }, redirect: "follow", signal: ctrl.signal });
2212
- if (res.status === 404) return fail("HTTP 404 for " + target);
2213
- if (!res.ok) return fail("HTTP " + res.status + " for " + target);
2214
- html = await res.text();
2215
- } finally { clearTimeout(timer); }
2216
- } catch (e) { return fail(e && e.name === "AbortError" ? "timeout fetching " + target : "network error: " + String((e && e.message) || e).slice(0, 120)); }
2217
- if (!html || !html.length) return fail("empty body for " + target);
2501
+ try {
2502
+ res = await fetchFn(url, { headers: { "user-agent": AWARD_USER_AGENT, accept: "text/html,*/*" }, redirect: "manual", signal: ctrl.signal });
2503
+ } catch (e) {
2504
+ return { ok: false, reason: timedOut ? "timeout fetching " + url : "network error: " + String((e && e.message) || e).slice(0, 120) };
2505
+ }
2506
+ if (res.status >= 300 && res.status < 400) {
2507
+ const loc = res.headers && typeof res.headers.get === "function" ? res.headers.get("location") : null;
2508
+ if (!loc) return { ok: false, reason: "redirect without location for " + url, status: res.status };
2509
+ if (hop === 3) return { ok: false, reason: "too many redirects for " + target };
2510
+ let next;
2511
+ try { next = awardValidateHttpUrl(new URL(loc, url).toString(), { allowLocal }); }
2512
+ catch (e) { return { ok: false, reason: "redirect blocked: " + ((e && e.message) || e), status: res.status }; }
2513
+ const hopBlock = await guard(next);
2514
+ if (hopBlock) return { ok: false, reason: hopBlock, status: res.status };
2515
+ url = next;
2516
+ continue;
2517
+ }
2518
+ if (res.status === 404) return { ok: false, reason: "HTTP 404 for " + url, status: 404 };
2519
+ if (!res.ok) return { ok: false, reason: "HTTP " + res.status + " for " + url, status: res.status };
2520
+ let html;
2521
+ try {
2522
+ html = await awardReadBoundedBody(res, maxBytes, label);
2523
+ res = null;
2524
+ } catch (e) {
2525
+ return { ok: false, reason: timedOut ? "timeout fetching " + url : String((e && e.message) || e).slice(0, 160) };
2526
+ }
2527
+ if (!html || !html.length) return { ok: false, reason: "empty body for " + url, status: 200 };
2528
+ return { ok: true, url: target, finalUrl: url, html };
2529
+ } finally {
2530
+ clearTimeout(timer);
2531
+ if (res && !res.bodyUsed) { try { if (res.body && typeof res.body.cancel === "function") await res.body.cancel(); } catch { /* socket already released */ } }
2532
+ }
2533
+ }
2534
+ return { ok: false, reason: "redirect loop for " + target };
2535
+ }
2536
+ async function awardStaticMotion(rawUrl, { fetchImpl, lookup = null, timeoutMs = AWARD_TIMEOUT_MS, maxBytes = 1500000, allowLocal = false } = {}) {
2537
+ if (typeof rawUrl !== "string" || !rawUrl.trim()) throw awardErr("BAD_ARGS", "url must be a non-empty string");
2538
+ const fail = (reason) => ({ status: "unavailable", url: String(rawUrl).slice(0, 200), reason: String(reason).slice(0, 200) });
2539
+ const got = await awardFetchGuardedHtml(rawUrl, { fetchImpl, lookup, timeoutMs, maxBytes, allowLocal, label: "static page" });
2540
+ if (!got.ok) return fail(got.reason);
2541
+ const html = got.html;
2218
2542
  const src = html.slice(0, maxBytes);
2219
2543
  const count = (re) => Math.min(999, (src.match(re) || []).length);
2220
2544
  const libs = AWARD_STATIC_LIBS.filter(({ res }) => res.some((re) => re.test(src))).map(({ lib }) => lib);
2221
2545
  return {
2222
- status: "static-markup", url: target,
2546
+ status: "static-markup", url: got.url,
2223
2547
  libs, keyframes: count(/@keyframes\s/gi), animations: count(/animation\s*:/gi), transitions: count(/transition\s*:/gi),
2224
2548
  canvases: count(/<canvas/gi), videos: count(/<video/gi),
2225
2549
  scrollCues: AWARD_STATIC_CUES.filter((c) => src.toLowerCase().includes(c)),
@@ -2304,7 +2628,7 @@ function awardCompareSites(entries) {
2304
2628
  }));
2305
2629
  return { per, sharedTech, sharedTechFamilies, sharedElements: elements.filter((t) => t.frequency >= 2), sharedElementKeywords: elemKeywords.filter((t) => t.frequency >= 2), sharedPalette: palette.filter((t) => t.frequency >= 2), sharedBands: bands, directionHints: direction };
2306
2630
  }
2307
- function awardBuildBlueprint({ goal, refs, details, auraMatches, structureNotes, motionNotes, constraints = [], technology = null, visualDirection = null, responsive = null, requestedPalette = [], gaps: extraGaps = [] }) {
2631
+ function awardBuildBlueprint({ goal, refs, details, auraMatches, structureNotes, motionNotes, motionRecorded = false, constraints = [], technology = null, visualDirection = null, responsive = null, requestedPalette = [], gaps: extraGaps = [] }) {
2308
2632
  if (!goal || !String(goal).trim()) throw awardBad("goal is required");
2309
2633
  const usable = (details || []).filter((d) => d && d.detail);
2310
2634
  const threshold = usable.length === 2 ? 2 : usable.length >= 3 ? Math.ceil(usable.length / 2) : Infinity;
@@ -2399,15 +2723,17 @@ function awardBuildBlueprint({ goal, refs, details, auraMatches, structureNotes,
2399
2723
  if (gaps.length) { for (const g of gaps) lines.push("- " + g); }
2400
2724
  else lines.push("- None material: refs, motion/structure evidence as cited above, catalogue matches listed.");
2401
2725
  // Evidence-scored confidence: base from usable ref count, +1 per
2402
- // independent axis actually present (matched sections, recorded motion,
2403
- // measured structure, fully-parsed tech). Thin evidence stays low;
2404
- // rich evidence may reach high — same honesty, both directions.
2726
+ // independent axis actually present (matched sections, RECORDED motion,
2727
+ // measured structure, fully-parsed tech). Static markup cues describe the
2728
+ // page source, not observed runtime behavior, so they never add a point.
2729
+ // Thin evidence stays low; rich evidence may reach high — same honesty,
2730
+ // both directions.
2405
2731
  const why = [];
2406
2732
  let points = Math.min(usable.length, 3);
2407
2733
  why.push(usable.length + " usable ref" + (usable.length === 1 ? "" : "s"));
2408
2734
  const sectionHits = matches.filter((m) => m.section !== "tokens" && !m.editorial).length;
2409
2735
  if (sectionHits >= 4) { points++; why.push(sectionHits + " catalogue sections matched"); }
2410
- if (motionNotes) { points++; why.push("recorded motion evidence"); }
2736
+ if (motionRecorded) { points++; why.push("recorded motion evidence"); }
2411
2737
  if (structureNotes && structureNotes.includes("(live-dom)")) { points++; why.push("measured structure"); }
2412
2738
  if (usable.length >= 2 && usable.every((d) => (d.detail.technologies || []).length && !d.detail.technologiesInferred)) { points++; why.push("fully-parsed technologies (not inferred)"); }
2413
2739
  const confidence = points <= 1 ? "low" : points <= 3 ? "low-medium" : points === 4 ? "medium" : "high";
@@ -2466,6 +2792,12 @@ function awardResolveDir(override) {
2466
2792
  return path.join(os.homedir(), ".aura-mcp", "awards");
2467
2793
  }
2468
2794
  function awardIdxBad(m) { return awardErr("BAD_ARGS", m); }
2795
+ // url watches may be stored canonically (slug) or as legacy raw records
2796
+ // (the old code stored the lowercased URL); both reduce to the same target
2797
+ // at match time so legacy entries keep working.
2798
+ function awardUrlWatchTarget(pattern) {
2799
+ try { return awardWatchTargetSlug(pattern); } catch { return String(pattern || "").trim().toLowerCase(); }
2800
+ }
2469
2801
 
2470
2802
  function createAwardIndex({ dir, now = () => Date.now() } = {}) {
2471
2803
  const { fs, path } = awardIdxBuiltins();
@@ -2508,7 +2840,7 @@ function createAwardIndex({ dir, now = () => Date.now() } = {}) {
2508
2840
  const emptyElements = () => ({ version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), items: [] });
2509
2841
  const emptyTax = () => ({ version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), colors: [], filters: [] });
2510
2842
  const emptyMotion = () => ({ version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), items: [] });
2511
- const emptyMeta = () => ({ version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), indexStatus: null, progress: [], baselines: {}, watches: [] });
2843
+ const emptyMeta = () => ({ version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), indexStatus: null, progress: [], baselines: {}, watches: [], crawl: null });
2512
2844
 
2513
2845
  function siteMap() {
2514
2846
  const doc = readJson("sites.json", emptySites());
@@ -2516,6 +2848,20 @@ function createAwardIndex({ dir, now = () => Date.now() } = {}) {
2516
2848
  for (const s of (doc.items || [])) if (s && s.slug) map.set(String(s.slug).toLowerCase(), s);
2517
2849
  return { doc, map };
2518
2850
  }
2851
+ function upsertSitesImpl(sites) {
2852
+ const { map } = siteMap();
2853
+ let added = 0, updated = 0;
2854
+ for (const s of (sites || [])) {
2855
+ if (!s || !s.slug) continue;
2856
+ const k = String(s.slug).toLowerCase();
2857
+ if (map.has(k)) { map.set(k, { ...map.get(k), ...s }); updated++; }
2858
+ else { map.set(k, s); added++; }
2859
+ }
2860
+ let items = [...map.values()];
2861
+ if (items.length > AWARD_MAX_SITES) items = items.slice(items.length - AWARD_MAX_SITES);
2862
+ writeAtomic("sites.json", { version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), items });
2863
+ return { added, updated, total: items.length };
2864
+ }
2519
2865
  function acquireLock() {
2520
2866
  const lockFile = path.join(root, "lock.json");
2521
2867
  try {
@@ -2530,6 +2876,112 @@ function createAwardIndex({ dir, now = () => Date.now() } = {}) {
2530
2876
  fs.writeFileSync(lockFile, JSON.stringify({ startedAt: now(), pid: (globalThis.process && globalThis.process.pid) || 0 }), "utf8");
2531
2877
  }
2532
2878
  function releaseLock() { try { fs.rmSync(path.join(root, "lock.json"), { force: true }); } catch { /* never mask */ } }
2879
+ // Lock must wrap the whole cycle: acquire first, release in finally; an
2880
+ // INDEX_BUSY throw from acquire never releases someone else's lock.
2881
+ const withLock = async (fn) => { acquireLock(); try { return await fn(); } finally { releaseLock(); } };
2882
+
2883
+ // One crawl cycle. Caller holds the lock. Returns a report naming the plan,
2884
+ // what was fetched this call, and what remains.
2885
+ const runCycle = async ({ client, action = "rebuild", tags = null, maxPages = 5, log = () => {}, fresh = false } = {}) => {
2886
+ if (!client || typeof client.getAwardHtml !== "function") throw awardIdxBad(action + " needs an award client");
2887
+ const startedAt = now();
2888
+ const iso = (t = now()) => new Date(t).toISOString();
2889
+ const budget = Math.max(1, Math.min(Number(maxPages) || 5, 20));
2890
+ const crawlView = (plan) => ({ planId: plan.planId || null, cycle: plan.cycle || 1, pagesTotal: plan.tags.length, pagesDone: plan.done.length, complete: !!plan.finishedAt, startedAt: plan.startedAt || null, finishedAt: plan.finishedAt || null });
2891
+ try {
2892
+ let meta = readJson("meta.json", emptyMeta());
2893
+ const explicit = Array.isArray(tags) && tags.length ? tags.map(String).slice(0, 200) : null;
2894
+ const stored = meta.crawl && Array.isArray(meta.crawl.tags) && meta.crawl.tags.length
2895
+ ? { ...meta.crawl, tags: meta.crawl.tags.map(String).slice(0, 200), done: (meta.crawl.done || []).map(String) }
2896
+ : null;
2897
+ if (action === "resume" && stored && stored.finishedAt) {
2898
+ // Completed cycle: resume is an explicit no-op, not a silent recrawl.
2899
+ return { pagesDone: 0, pagesTotal: stored.tags.length, sitesIndexed: 0, skipped: stored.done.length, completedPages: stored.done.length, complete: true, continued: false, crawl: crawlView(stored), note: "crawl cycle already complete (" + stored.done.length + "/" + stored.tags.length + " pages); use action:refresh to start a new cycle" };
2900
+ }
2901
+ const explicitMatches = !!(explicit && stored && [...stored.tags].sort().join("|") === [...explicit].sort().join("|"));
2902
+ const tagsIgnored = !!(explicit && action === "resume" && stored && !explicitMatches);
2903
+ const continueStored = !!(stored && !stored.finishedAt && !fresh && (action === "resume" || !explicit || explicitMatches));
2904
+ let plan = null;
2905
+ const continued = continueStored;
2906
+ if (continueStored) plan = stored;
2907
+ if (!plan) {
2908
+ // A new plan needs the tag list: explicit tags, or the taxonomy
2909
+ // (bootstrapped with one fetch when the index has none yet).
2910
+ let wanted = explicit;
2911
+ if (!wanted) {
2912
+ let tax = readJson("taxonomy.json", emptyTax());
2913
+ if (!(tax.filters || []).length) {
2914
+ const { html } = await client.getAwardHtml("/websites/");
2915
+ tax = { version: AWARD_INDEX_VERSION, updatedAt: iso(), ...awardParseCategories(html) };
2916
+ if (!(tax.filters || []).length) throw awardErr("UPSTREAM_ERROR", "awwwards layout may have changed: parsed 0 categories");
2917
+ writeAtomic("taxonomy.json", tax);
2918
+ }
2919
+ wanted = (tax.filters || []).map(String).slice(0, 200);
2920
+ }
2921
+ if (!wanted.length) throw awardErr("UPSTREAM_ERROR", "no taxonomy to crawl; index has no filters");
2922
+ // Legacy checkpoint (a cycle from before plans existed): keep the
2923
+ // completed pages that are still part of this plan.
2924
+ const seedDone = [];
2925
+ if (!fresh && !stored && Array.isArray(meta.progress)) {
2926
+ for (const t of meta.progress.map(String)) if (wanted.includes(t) && !seedDone.includes(t)) seedDone.push(t);
2927
+ }
2928
+ plan = { planId: "cycle-" + startedAt, cycle: ((stored && stored.cycle) || 0) + 1, tags: wanted, done: seedDone, sitesIndexed: 0, startedAt: iso(), finishedAt: null };
2929
+ // Persist the plan immediately so a crash before page 1 still leaves
2930
+ // resumable state instead of an orphaned taxonomy fetch.
2931
+ meta.crawl = plan;
2932
+ meta.progress = [...plan.done];
2933
+ meta.updatedAt = iso();
2934
+ writeAtomic("meta.json", meta);
2935
+ }
2936
+ let pagesFetched = 0, sitesIndexed = 0;
2937
+ const skippedAtStart = plan.done.length;
2938
+ for (const tag of plan.tags) {
2939
+ if (plan.done.includes(tag)) continue;
2940
+ if (pagesFetched >= budget) break;
2941
+ const { html } = await client.getAwardHtml("/websites/" + encodeURIComponent(tag) + "/");
2942
+ const scan = awardParseListingEx(html);
2943
+ // Drift (collectable markup we can no longer read) aborts the cycle
2944
+ // with a named reason; a legitimately empty/filtered page is just 0
2945
+ // sites and the plan moves on.
2946
+ if (scan.diagnostics.verdict === "drift-empty") {
2947
+ throw awardErr("UPSTREAM_ERROR", "awwwards collector drift on /websites/" + tag + "/: no readable cards (parser v" + AWARD_PARSER_VERSION + ", blobs=" + scan.diagnostics.blobs + ", malformed=" + scan.diagnostics.malformed + ") — page not indexed, checkpoint kept");
2948
+ }
2949
+ const sites = scan.sites;
2950
+ if (sites.length) upsertSitesImpl(sites.map((s) => ({ ...s, tags: [...new Set([...(s.tags || []), tag])] })));
2951
+ sitesIndexed += sites.length;
2952
+ pagesFetched++;
2953
+ plan.done.push(tag);
2954
+ plan.sitesIndexed = (plan.sitesIndexed || 0) + sites.length;
2955
+ meta = readJson("meta.json", emptyMeta());
2956
+ meta.crawl = plan;
2957
+ meta.progress = [...plan.done];
2958
+ meta.updatedAt = iso();
2959
+ writeAtomic("meta.json", meta);
2960
+ log("[" + plan.done.length + "/" + plan.tags.length + "] " + tag + ": " + sites.length + " sites" + (sites.length ? "" : " (legit empty page)"));
2961
+ }
2962
+ const complete = plan.done.length >= plan.tags.length;
2963
+ if (complete) plan.finishedAt = iso();
2964
+ meta = readJson("meta.json", emptyMeta());
2965
+ meta.crawl = plan;
2966
+ meta.progress = complete ? [] : [...plan.done];
2967
+ if (complete) meta.indexStatus = { startedAt: plan.startedAt, finishedAt: plan.finishedAt, pagesDone: plan.done.length, pagesTotal: plan.tags.length, sitesIndexed: plan.sitesIndexed || 0 };
2968
+ meta.updatedAt = iso();
2969
+ writeAtomic("meta.json", meta);
2970
+ const out = { pagesDone: pagesFetched, pagesTotal: plan.tags.length, sitesIndexed, skipped: skippedAtStart, completedPages: plan.done.length, complete, continued, crawl: crawlView(plan) };
2971
+ if (complete) out.note = "crawl cycle complete (" + plan.done.length + "/" + plan.tags.length + " pages); refresh starts a new cycle";
2972
+ else if (pagesFetched >= budget) out.note = "page budget reached (" + pagesFetched + " this call, " + plan.done.length + "/" + plan.tags.length + " total); action:resume continues the same plan";
2973
+ else out.note = "no pending pages fetched"; // completes mid-call only when the plan finished exactly at the budget boundary
2974
+ if (tagsIgnored) out.note = "tags argument ignored: resume continues the stored plan | " + out.note;
2975
+ return out;
2976
+ } catch (e) {
2977
+ try {
2978
+ const meta = readJson("meta.json", emptyMeta());
2979
+ meta.indexStatus = { ...(meta.indexStatus || {}), lastError: String((e && e.message) || e).slice(0, 300) };
2980
+ writeAtomic("meta.json", meta);
2981
+ } catch { /* never mask */ }
2982
+ throw e;
2983
+ }
2984
+ };
2533
2985
 
2534
2986
  return {
2535
2987
  root,
@@ -2547,14 +2999,33 @@ function createAwardIndex({ dir, now = () => Date.now() } = {}) {
2547
2999
  } catch { lock = { unreadable: true }; }
2548
3000
  const finishedAt = meta.indexStatus && meta.indexStatus.finishedAt;
2549
3001
  const freshness = finishedAt ? new Date(finishedAt).toISOString() : null;
3002
+ const crawl = meta.crawl ? {
3003
+ planId: meta.crawl.planId || null,
3004
+ cycle: meta.crawl.cycle || 1,
3005
+ pagesTotal: (meta.crawl.tags || []).length,
3006
+ pagesDone: (meta.crawl.done || []).length,
3007
+ complete: !!meta.crawl.finishedAt,
3008
+ startedAt: meta.crawl.startedAt || null,
3009
+ finishedAt: meta.crawl.finishedAt || null,
3010
+ } : null;
3011
+ const recovered = [];
3012
+ if (sites.corrupt) recovered.push("sites.json: " + sites.corrupt);
3013
+ if (elements.corrupt) recovered.push("elements.json: " + elements.corrupt);
3014
+ if (motion.corrupt) recovered.push("motion.json: " + motion.corrupt);
3015
+ if (tax.corrupt) recovered.push("taxonomy.json: " + tax.corrupt);
3016
+ if (meta.corrupt) recovered.push("meta.json: " + meta.corrupt);
2550
3017
  return {
2551
3018
  dir: root, version: AWARD_INDEX_VERSION,
2552
3019
  counts: { sites: (sites.items || []).length, elements: (elements.items || []).length, motion: (motion.items || []).length, taxonomyFilters: (tax.filters || []).length, taxonomyColors: (tax.colors || []).length, watches: (meta.watches || []).length },
2553
3020
  freshness, stale: !finishedAt || (now() - finishedAt >= AWARD_INDEX_STALE_MS),
2554
- progress: meta.indexStatus || null, pendingPages: meta.progress || [],
3021
+ progress: meta.indexStatus || null,
3022
+ pendingPages: meta.progress || [],
3023
+ completedPages: crawl ? crawl.pagesDone : (meta.progress || []).length,
3024
+ crawl,
2555
3025
  lock: lock ? { held: true, since: lock.startedAt ? new Date(lock.startedAt).toISOString() : null } : { held: false },
2556
3026
  ...(sites.corrupt ? { sitesCorrupt: sites.corrupt } : {}),
2557
3027
  ...(meta.corrupt ? { metaCorrupt: meta.corrupt } : {}),
3028
+ ...(recovered.length ? { recovered } : {}),
2558
3029
  };
2559
3030
  },
2560
3031
  listSites(limit = 50) {
@@ -2566,20 +3037,7 @@ function createAwardIndex({ dir, now = () => Date.now() } = {}) {
2566
3037
  const { map } = siteMap();
2567
3038
  return map.get(n) || null;
2568
3039
  },
2569
- upsertSites(sites) {
2570
- const { doc, map } = siteMap();
2571
- let added = 0, updated = 0;
2572
- for (const s of (sites || [])) {
2573
- if (!s || !s.slug) continue;
2574
- const k = String(s.slug).toLowerCase();
2575
- if (map.has(k)) { map.set(k, { ...map.get(k), ...s }); updated++; }
2576
- else { map.set(k, s); added++; }
2577
- }
2578
- let items = [...map.values()];
2579
- if (items.length > AWARD_MAX_SITES) items = items.slice(items.length - AWARD_MAX_SITES);
2580
- writeAtomic("sites.json", { version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), items });
2581
- return { added, updated, total: items.length };
2582
- },
3040
+ upsertSites(sites) { return upsertSitesImpl(sites); },
2583
3041
  getTaxonomy() { return readJson("taxonomy.json", emptyTax()); },
2584
3042
  setTaxonomy(tax) {
2585
3043
  const colors = Array.isArray(tax.colors) ? [...new Set(tax.colors.map(String))].slice(0, 100) : [];
@@ -2626,98 +3084,73 @@ function createAwardIndex({ dir, now = () => Date.now() } = {}) {
2626
3084
  return { total: items.length, items: items.slice(0, Math.max(1, Math.min(limit || 10, 25))), empty: (doc.items || []).length === 0 };
2627
3085
  },
2628
3086
  // Index lifecycle: crawl tag pages via client, resumable, bounded.
2629
- async rebuild({ client, tags, log = () => {} } = {}) {
2630
- if (!client || typeof client.getAwardHtml !== "function") throw awardIdxBad("rebuild needs an award client");
2631
- acquireLock();
2632
- const startedAt = now();
2633
- try {
2634
- let meta = readJson("meta.json", emptyMeta());
2635
- let tax = readJson("taxonomy.json", emptyTax());
2636
- if ((!tax.filters || !tax.filters.length)) {
2637
- const { html } = await client.getAwardHtml("/websites/");
2638
- tax = { version: AWARD_INDEX_VERSION, updatedAt: new Date(now()).toISOString(), ...awardParseCategories(html) };
2639
- if (!(tax.filters || []).length) throw awardErr("UPSTREAM_ERROR", "awwwards layout may have changed: parsed 0 categories");
2640
- writeAtomic("taxonomy.json", tax);
2641
- }
2642
- const wanted = Array.isArray(tags) && tags.length ? tags.map(String).slice(0, 200) : (tax.filters || []).slice(0, 200);
2643
- if (!wanted.length) throw awardErr("UPSTREAM_ERROR", "no taxonomy to crawl; index has no filters");
2644
- const done = new Set(meta.progress || []);
2645
- // Completed checkpoint from a prior cycle is not resumable work.
2646
- if (meta.indexStatus && meta.indexStatus.finishedAt && done.size >= (meta.indexStatus.pagesTotal || 0)) { meta.progress = []; done.clear(); }
2647
- let pagesDone = 0, sitesIndexed = 0;
2648
- const skipped = [...wanted].filter((t) => done.has(t)).length;
2649
- for (const tag of wanted) {
2650
- if (done.has(tag)) continue;
2651
- const { html } = await client.getAwardHtml("/websites/" + encodeURIComponent(tag) + "/");
2652
- const sites = awardParseListing(html);
2653
- if (!sites.length) throw awardErr("UPSTREAM_ERROR", "awwwards layout may have changed: 0 cards on /websites/" + tag + "/");
2654
- this.upsertSites(sites.map((s) => ({ ...s, tags: [...new Set([...(s.tags || []), tag])] })));
2655
- sitesIndexed += sites.length;
2656
- pagesDone++;
2657
- done.add(tag);
2658
- meta = readJson("meta.json", emptyMeta());
2659
- meta.progress = [...done];
2660
- meta.updatedAt = new Date(now()).toISOString();
2661
- writeAtomic("meta.json", meta);
2662
- log("[" + done.size + "/" + wanted.length + "] " + tag + ": " + sites.length + " sites");
2663
- }
2664
- meta = readJson("meta.json", emptyMeta());
2665
- meta.indexStatus = { startedAt, finishedAt: now(), pagesDone: done.size, pagesTotal: wanted.length, sitesIndexed };
2666
- meta.progress = [];
2667
- meta.updatedAt = new Date(now()).toISOString();
2668
- writeAtomic("meta.json", meta);
2669
- return { pagesDone, pagesTotal: wanted.length, sitesIndexed, skipped };
2670
- } catch (e) {
2671
- try {
2672
- const meta = readJson("meta.json", emptyMeta());
2673
- meta.indexStatus = { ...(meta.indexStatus || {}), lastError: String((e && e.message) || e).slice(0, 300) };
2674
- writeAtomic("meta.json", meta);
2675
- } catch { /* never mask */ }
2676
- throw e;
2677
- } finally { releaseLock(); }
2678
- },
2679
- async resume(args) { return this.rebuild(args); },
2680
- async refresh(args) {
2681
- const meta = readJson("meta.json", emptyMeta());
2682
- meta.progress = [];
2683
- writeAtomic("meta.json", meta);
2684
- return this.rebuild(args);
2685
- },
2686
- // Winners: baseline seed then delta (never fabricate dates).
3087
+ // A "cycle" is one plan (full tag list) plus a done-checkpoint set.
3088
+ // rebuild continues an unfinished cycle, or starts a new one when the
3089
+ // previous cycle completed; resume only continues pending work and
3090
+ // reports an explicit no-op when the cycle is complete; refresh resets
3091
+ // and starts a new cycle after the lock is held (no pre-lock mutation).
3092
+ // maxPages caps fetches per call, so a plan advances across calls
3093
+ // instead of restarting the same prefix every resume.
3094
+ async rebuild(args = {}) { return withLock(() => runCycle({ ...args, action: "rebuild" })); },
3095
+ async resume(args = {}) { return withLock(() => runCycle({ ...args, action: "resume" })); },
3096
+ async refresh(args = {}) { return withLock(() => runCycle({ ...args, action: "refresh", fresh: true })); },
3097
+ // Winners: baseline seed then delta. Drift or an empty listing never
3098
+ // wipes the baseline — otherwise every known winner would be re-reported
3099
+ // as new on the next healthy fetch.
2687
3100
  async newWinners({ client, award = "sotd" } = {}) {
2688
3101
  if (!client || typeof client.getAwardHtml !== "function") throw awardIdxBad("newWinners needs an award client");
2689
3102
  if (!AWARD_FILTERS[award]) throw awardIdxBad("award must be sotd|developer|honorable");
2690
3103
  const { html } = await client.getAwardHtml("/" + AWARD_FILTERS[award] + "/");
2691
- const parsed = awardParseListing(html);
3104
+ const scan = awardParseListingEx(html);
3105
+ const parsed = scan.sites;
3106
+ const diag = scan.diagnostics;
3107
+ const parserStamp = { parserVersion: AWARD_PARSER_VERSION, verdict: diag.verdict, blobs: diag.blobs, malformed: diag.malformed };
2692
3108
  const meta = readJson("meta.json", emptyMeta());
2693
3109
  const key = "baseline_" + award;
2694
3110
  const prev = meta.baselines && meta.baselines[key];
2695
3111
  const day = new Date(now()).toISOString().slice(0, 10);
3112
+ if (diag.verdict === "drift-empty") {
3113
+ return { day, seeded: false, newWinners: [], totalInListing: 0, degraded: true, parser: parserStamp, note: "listing returned no readable cards (collector drift?); baseline preserved — retry later" };
3114
+ }
3115
+ if (!parsed.length) {
3116
+ const prevSlugs = (prev && prev.slugs) || [];
3117
+ if (prevSlugs.length) return { day, seeded: false, newWinners: [], totalInListing: 0, degraded: true, parser: parserStamp, note: "listing had 0 readable cards; kept the previous baseline of " + prevSlugs.length + " slugs so nothing is re-reported as new" };
3118
+ if (!prev) {
3119
+ meta.baselines = { ...(meta.baselines || {}), [key]: { slugs: [], at: new Date(now()).toISOString() } };
3120
+ writeAtomic("meta.json", meta);
3121
+ return { day, seeded: true, newWinners: [], totalInListing: 0, parser: parserStamp, note: "baseline seeded empty; this listing had no readable cards (not drift)" };
3122
+ }
3123
+ return { day, seeded: false, newWinners: [], totalInListing: 0, parser: parserStamp };
3124
+ }
3125
+ const slugs = parsed.map((s) => s.slug);
2696
3126
  if (!prev) {
2697
- meta.baselines = { ...(meta.baselines || {}), [key]: { slugs: parsed.map((s) => s.slug), at: new Date(now()).toISOString() } };
3127
+ meta.baselines = { ...(meta.baselines || {}), [key]: { slugs, at: new Date(now()).toISOString() } };
2698
3128
  writeAtomic("meta.json", meta);
2699
3129
  this.upsertSites(parsed);
2700
- return { day, seeded: true, newWinners: [], totalInListing: parsed.length, note: "baseline seeded; no historical delta available yet" };
3130
+ return { day, seeded: true, newWinners: [], totalInListing: parsed.length, parser: parserStamp, note: "baseline seeded; no historical delta available yet" };
2701
3131
  }
2702
3132
  const seen = new Set(prev.slugs || []);
2703
3133
  const fresh = parsed.filter((s) => !seen.has(s.slug));
2704
- meta.baselines[key] = { slugs: parsed.map((s) => s.slug), at: new Date(now()).toISOString() };
3134
+ meta.baselines[key] = { slugs, at: new Date(now()).toISOString() };
2705
3135
  writeAtomic("meta.json", meta);
2706
- if (parsed.length) this.upsertSites(parsed);
2707
- return { day, seeded: false, newWinners: fresh, totalInListing: parsed.length };
3136
+ this.upsertSites(parsed);
3137
+ return { day, seeded: false, newWinners: fresh, totalInListing: parsed.length, parser: parserStamp };
2708
3138
  },
2709
3139
  watchAdd({ kind, pattern, award } = {}) {
2710
3140
  if (!["studio", "tag", "url"].includes(kind)) throw awardIdxBad("watch kind must be studio|tag|url");
2711
3141
  if (typeof pattern !== "string" || !pattern.trim() || pattern.length > 120) throw awardIdxBad("watch pattern must be 1-120 chars");
2712
3142
  const clean = pattern.trim().toLowerCase();
2713
- if (kind === "url") awardNormalizeSlug(clean);
3143
+ // url watches normalize to the site slug (a URL and its slug are the
3144
+ // same target; storing the raw URL never matched an indexed slug).
3145
+ const stored = kind === "url" ? awardWatchTargetSlug(pattern) : clean;
2714
3146
  if (kind === "tag" && !/^[a-z0-9-]{2,60}$/.test(clean)) throw awardIdxBad("tag watch must be a slug like 'webgl'");
2715
3147
  if (award !== undefined && !["sotd", "developer", "honorable"].includes(award)) throw awardIdxBad("watch award must be sotd|developer|honorable");
2716
3148
  const meta = readJson("meta.json", emptyMeta());
2717
3149
  const watches = meta.watches || [];
2718
- if (watches.some((w) => w.kind === kind && w.pattern === clean)) throw awardIdxBad("duplicate watch: " + kind + ":" + clean);
3150
+ const duplicate = watches.some((w) => w.kind === kind && (w.pattern === stored || (kind === "url" && awardUrlWatchTarget(w.pattern) === stored)));
3151
+ if (duplicate) throw awardIdxBad("duplicate watch: " + kind + ":" + stored);
2719
3152
  if (watches.length >= 50) throw awardIdxBad("too many watches (max 50)");
2720
- const rec = { kind, pattern: clean, award: award || null, createdAt: new Date(now()).toISOString(), lastCheckedAt: null, lastSlugs: null };
3153
+ const rec = { kind, pattern: stored, ...(stored !== clean ? { input: pattern.trim() } : {}), award: award || null, createdAt: new Date(now()).toISOString(), lastCheckedAt: null, lastSlugs: null };
2721
3154
  meta.watches = [...watches, rec];
2722
3155
  writeAtomic("meta.json", meta);
2723
3156
  return rec;
@@ -2729,20 +3162,25 @@ function createAwardIndex({ dir, now = () => Date.now() } = {}) {
2729
3162
  watchRemove({ kind, pattern } = {}) {
2730
3163
  const meta = readJson("meta.json", emptyMeta());
2731
3164
  const clean = String(pattern || "").trim().toLowerCase();
3165
+ const target = kind === "url" ? awardUrlWatchTarget(clean) : clean;
2732
3166
  const before = (meta.watches || []).length;
2733
- meta.watches = (meta.watches || []).filter((w) => !(w.kind === kind && w.pattern === clean));
3167
+ meta.watches = (meta.watches || []).filter((w) => !(w.kind === kind && (w.pattern === clean || (kind === "url" && awardUrlWatchTarget(w.pattern) === target))));
2734
3168
  writeAtomic("meta.json", meta);
2735
3169
  return { removed: (meta.watches.length < before) };
2736
3170
  },
2737
- checkWatches() {
3171
+ // First evaluation of a watch seeds its baseline silently (deltas stay
3172
+ // empty). Detailed form discloses the seeds so callers can show what a
3173
+ // watch is now tracking; checkWatches keeps the pinned deltas-only shape.
3174
+ checkWatchesDetailed() {
2738
3175
  const meta = readJson("meta.json", emptyMeta());
2739
3176
  const { map } = siteMap();
2740
3177
  const rows = [...map.values()];
2741
- const out = [];
3178
+ const deltas = [];
3179
+ const seeded = [];
2742
3180
  const watches = (meta.watches || []).map((w) => ({ ...w }));
2743
3181
  for (const w of watches) {
2744
3182
  const hits = rows.filter((s) => {
2745
- if (w.kind === "url") return String(s.slug) === w.pattern;
3183
+ if (w.kind === "url") return String(s.slug) === awardUrlWatchTarget(w.pattern);
2746
3184
  if (w.kind === "studio") return String(s.studio || "").toLowerCase().includes(w.pattern);
2747
3185
  return (s.tags || []).some((t) => String(t).toLowerCase().replace(/\W+/g, "-") === w.pattern);
2748
3186
  });
@@ -2750,18 +3188,20 @@ function createAwardIndex({ dir, now = () => Date.now() } = {}) {
2750
3188
  if (w.lastSlugs === null || w.lastSlugs === undefined) {
2751
3189
  w.lastCheckedAt = new Date(now()).toISOString();
2752
3190
  w.lastSlugs = slugs;
3191
+ if (hits.length) seeded.push({ watch: { kind: w.kind, pattern: w.pattern }, baselineMatches: hits.map((s) => ({ slug: s.slug, title: s.title, sourceUrl: s.sourceUrl })) });
2753
3192
  continue;
2754
3193
  }
2755
3194
  const prev = new Set(w.lastSlugs || []);
2756
3195
  const fresh = hits.filter((s) => !prev.has(s.slug));
2757
3196
  w.lastCheckedAt = new Date(now()).toISOString();
2758
3197
  w.lastSlugs = slugs;
2759
- if (fresh.length) out.push({ watch: { kind: w.kind, pattern: w.pattern }, matches: fresh.map((s) => ({ slug: s.slug, title: s.title, sourceUrl: s.sourceUrl })) });
3198
+ if (fresh.length) deltas.push({ watch: { kind: w.kind, pattern: w.pattern }, matches: fresh.map((s) => ({ slug: s.slug, title: s.title, sourceUrl: s.sourceUrl })) });
2760
3199
  }
2761
3200
  meta.watches = watches;
2762
3201
  writeAtomic("meta.json", meta);
2763
- return out;
3202
+ return { deltas, seeded };
2764
3203
  },
3204
+ checkWatches() { return this.checkWatchesDetailed().deltas; },
2765
3205
  };
2766
3206
  }
2767
3207
 
@@ -2869,6 +3309,42 @@ function cdpBandsFromHtml(html, url) {
2869
3309
  return { url, title: (src.match(/<title>([^<]{1,120})<\/title>/i) || [])[1] || url, totalHeight: null, bands, source: "static-html", note: "offsets/heights unavailable without layout; use capture/structure via browser for measured bands" };
2870
3310
  }
2871
3311
 
3312
+ // Bounded artifact retention: only ever prunes tool-created files/dirs in
3313
+ // tool-owned locations (tmp default dirs), never caller-passed saveDirs.
3314
+ function cdpPruneOldFiles(dir, { prefix, keepNewest = 20 } = {}) {
3315
+ try {
3316
+ const { fs, path } = cdpBuiltins();
3317
+ const entries = fs.readdirSync(dir).filter((n) => n.startsWith(prefix)).map((n) => {
3318
+ const full = path.join(dir, n);
3319
+ let t = 0;
3320
+ try { t = fs.statSync(full).mtimeMs || 0; } catch { /* skipped below by mtime 0 */ }
3321
+ return { full, t };
3322
+ }).filter((e) => e.t > 0).sort((a, b) => b.t - a.t);
3323
+ let removed = 0;
3324
+ for (const e of entries.slice(Math.max(1, keepNewest))) { try { fs.rmSync(e.full, { force: true }); removed++; } catch { /* ignore */ } }
3325
+ return removed;
3326
+ } catch { return 0; }
3327
+ }
3328
+ function cdpPruneOldDirs(parentDir, { prefix, maxAgeMs = 24 * 60 * 60 * 1000, skip = null } = {}) {
3329
+ try {
3330
+ const { fs, path } = cdpBuiltins();
3331
+ const nowMs = Date.now();
3332
+ let removed = 0;
3333
+ const skipResolved = skip ? path.resolve(skip) : null;
3334
+ for (const n of fs.readdirSync(parentDir)) {
3335
+ if (!n.startsWith(prefix)) continue;
3336
+ const full = path.join(parentDir, n);
3337
+ if (skipResolved && path.resolve(full) === skipResolved) continue;
3338
+ let st;
3339
+ try { st = fs.statSync(full); } catch { continue; }
3340
+ if (!st.isDirectory()) continue;
3341
+ if (nowMs - (st.mtimeMs || 0) < maxAgeMs) continue;
3342
+ try { fs.rmSync(full, { recursive: true, force: true }); removed++; } catch { /* ignore */ }
3343
+ }
3344
+ return removed;
3345
+ } catch { return 0; }
3346
+ }
3347
+
2872
3348
  // CDP connection: id-keyed commands over a WebSocket-like transport.
2873
3349
  // Messages without an id are domain events (Page.loadEventFired, ...)
2874
3350
  // dispatched to onEvent subscribers.
@@ -3074,6 +3550,26 @@ function createBrowserAdapter({ chromePath = null, fetchImpl = null, socketImpl
3074
3550
  return out;
3075
3551
  } finally { clearTimeout(stopTimer); cleanup(); }
3076
3552
  }
3553
+ // Post-navigation URL guard: the pre-flight only validates the REQUESTED
3554
+ // url, but Chrome follows server-side redirects on its own — a public URL
3555
+ // that lands on a blocked host (private IP, metadata endpoint, localhost)
3556
+ // must not be read any further. Reads the committed URL via CDP; a
3557
+ // non-string value (test doubles, detached contexts) is skipped rather
3558
+ // than failed, so honest timeouts stay honest.
3559
+ async function cdpNavGuard(send, allowLocal) {
3560
+ let href = null;
3561
+ try {
3562
+ const res = await send("Runtime.evaluate", { expression: "location.href", returnByValue: true }, 5000);
3563
+ href = res && res.result && typeof res.result.value === "string" ? res.result.value : null;
3564
+ } catch { return; }
3565
+ if (!href || !/^https?:\/\//i.test(href)) return;
3566
+ try { awardValidateHttpUrl(href, { allowLocal }); }
3567
+ catch (e) {
3568
+ const err = awardErr("UNSUPPORTED_OPERATION", "navigation ended on a blocked URL: " + href + " (" + String((e && e.message) || e).slice(0, 160) + ")");
3569
+ err.blockedUrl = href;
3570
+ throw err;
3571
+ }
3572
+ }
3077
3573
  return {
3078
3574
  status() {
3079
3575
  let exe = null;
@@ -3093,6 +3589,7 @@ function createBrowserAdapter({ chromePath = null, fetchImpl = null, socketImpl
3093
3589
  const settled = waitForSettled(conn, send, waitStrategy);
3094
3590
  await send("Page.navigate", { url: target }, CDP_NAV_TIMEOUT_MS);
3095
3591
  await settled;
3592
+ await cdpNavGuard(send, allowLocal);
3096
3593
  await new Promise((r) => setTimeout(r, settle));
3097
3594
  // Pre-scroll for lazy sections (bounded).
3098
3595
  try { await send("Runtime.evaluate", { expression: "(async()=>{for(let y=0;y<Math.min(document.documentElement.scrollHeight,10800);y+=450){scrollTo(0,y);await new Promise(r=>setTimeout(r,40));}scrollTo(0,0);await new Promise(r=>setTimeout(r,150));})()", awaitPromise: true }, 8000); } catch { /* best-effort */ }
@@ -3102,11 +3599,27 @@ function createBrowserAdapter({ chromePath = null, fetchImpl = null, socketImpl
3102
3599
  },
3103
3600
  });
3104
3601
  const dir = saveDir || path.join(os.tmpdir(), "aura-captures");
3105
- try { fs.mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
3106
- const name = "capture-" + String(now()) + ".png";
3107
- const file = path.join(dir, name);
3108
- try { fs.writeFileSync(file, Buffer.from(shot.png, "base64")); } catch { /* return inline only */ }
3109
- return { url: target, viewport, viewportPx: cdpResolveViewport(viewport), capturedAt: startedAt, artifactPath: file, bytes: Buffer.from(shot.png, "base64").length, page: shot.meta, limitations: ["lazy-loaded sections below the fold may still be placeholders", "blocked third-party resources are not retried"], source: "live-browser" };
3602
+ const png = Buffer.from(shot.png, "base64");
3603
+ let artifactPath = null;
3604
+ let artifactNote = null;
3605
+ try {
3606
+ fs.mkdirSync(dir, { recursive: true });
3607
+ const file = path.join(dir, "capture-" + String(now()) + ".png");
3608
+ fs.writeFileSync(file, png);
3609
+ artifactPath = file;
3610
+ } catch (e) {
3611
+ artifactNote = "screenshot kept in memory only — could not write to " + dir + " (" + String((e && e.message) || e).slice(0, 120) + ")";
3612
+ }
3613
+ // Retention: only for tool-owned tmp dirs (never a caller saveDir) —
3614
+ // keep the newest 20 captures so long agent sessions cannot fill tmp.
3615
+ let retentionNote = null;
3616
+ if (!saveDir) {
3617
+ try {
3618
+ const removed = cdpPruneOldFiles(dir, { prefix: "capture-", keepNewest: 20 });
3619
+ if (removed > 0) retentionNote = "pruned " + removed + " older capture file(s) (newest 20 kept in " + dir + "; pass saveDir to manage your own directory)";
3620
+ } catch { /* best-effort */ }
3621
+ }
3622
+ return { url: target, viewport, viewportPx: cdpResolveViewport(viewport), capturedAt: startedAt, ...(artifactPath ? { artifactPath } : {}), ...(artifactNote ? { artifactNote } : {}), ...(retentionNote ? { retentionNote } : {}), bytes: png.length, page: shot.meta, limitations: ["lazy-loaded sections below the fold may still be placeholders", "blocked third-party resources are not retried"], source: "live-browser" };
3110
3623
  },
3111
3624
  async structure(url, { viewport = "desktop", allowLocal = false } = {}) {
3112
3625
  const target = awardValidateHttpUrl(url, { allowLocal });
@@ -3118,6 +3631,7 @@ function createBrowserAdapter({ chromePath = null, fetchImpl = null, socketImpl
3118
3631
  const settled = waitForSettled(conn, send, "load");
3119
3632
  await send("Page.navigate", { url: target }, CDP_NAV_TIMEOUT_MS);
3120
3633
  await settled;
3634
+ await cdpNavGuard(send, allowLocal);
3121
3635
  const res = await send("Runtime.evaluate", { expression: CDP_SCAN_JS, returnByValue: true }, 10000);
3122
3636
  return (res && res.result && res.result.value) || { title: target, totalHeight: 0, candidates: [] };
3123
3637
  },
@@ -3135,6 +3649,7 @@ function createBrowserAdapter({ chromePath = null, fetchImpl = null, socketImpl
3135
3649
  const settled = waitForSettled(conn, send, "load");
3136
3650
  await send("Page.navigate", { url: target }, CDP_NAV_TIMEOUT_MS);
3137
3651
  await settled;
3652
+ await cdpNavGuard(send, allowLocal);
3138
3653
  await new Promise((r) => setTimeout(r, 1500));
3139
3654
  const res = await send("Runtime.evaluate", { expression: CDP_MOTION_JS, returnByValue: true }, 10000);
3140
3655
  return (res && res.result && res.result.value) || { libs: [], render: [], scrollTrigger: { count: 0 }, tweens: { durations: [], eases: [] }, cssBeziers: [] };
@@ -3160,6 +3675,7 @@ function createBrowserAdapter({ chromePath = null, fetchImpl = null, socketImpl
3160
3675
  const settled = waitForSettled(conn, send, "load");
3161
3676
  await send("Page.navigate", { url: target }, CDP_NAV_TIMEOUT_MS);
3162
3677
  await settled;
3678
+ await cdpNavGuard(send, allowLocal);
3163
3679
  await new Promise((r) => setTimeout(r, Math.min(4000, stepMs)));
3164
3680
  const files = [];
3165
3681
  for (let i = 0; i < n; i++) {
@@ -3183,17 +3699,34 @@ function createBrowserAdapter({ chromePath = null, fetchImpl = null, socketImpl
3183
3699
  },
3184
3700
  });
3185
3701
  const study = out.inspect ? reduceCdpScan(target, out.inspect, now(), viewport) : null;
3186
- return { url: target, viewport, startedAt, frames: out.files, frameCount: out.files.length, study, dir };
3702
+ // Retention: only prunes tool-created sibling dirs in tmp (never a
3703
+ // caller saveDir), and never the current run's directory.
3704
+ let retentionNote = null;
3705
+ if (!saveDir) {
3706
+ try {
3707
+ const removed = cdpPruneOldDirs(os.tmpdir(), { prefix: "aura-motion-", maxAgeMs: 24 * 60 * 60 * 1000, skip: dir });
3708
+ if (removed > 0) retentionNote = "pruned " + removed + " prior motion-study dir(s) older than 24h (this run's frames kept at " + dir + ")";
3709
+ } catch { /* best-effort */ }
3710
+ }
3711
+ return { url: target, viewport, startedAt, frames: out.files, frameCount: out.files.length, study, dir, ...(retentionNote ? { retentionNote } : {}) };
3187
3712
  },
3188
3713
  async encodeWebm(frameFiles, { ffmpegPath = null } = {}) {
3714
+ if (!Array.isArray(frameFiles) || !frameFiles.length) throw cdpBad("no frames to encode");
3189
3715
  const bin = ffmpegPath || findFfmpeg({});
3190
3716
  if (!bin) return { video: null, available: false, hint: "ffmpeg not found; install ffmpeg or set AURA_FFMPEG_PATH for .webm output" };
3191
- if (!Array.isArray(frameFiles) || !frameFiles.length) throw cdpBad("no frames to encode");
3192
- const dir = path.dirname(frameFiles[0]);
3717
+ // Only encode frames that actually exist on disk, bounded to the
3718
+ // recording cap; verify they share one directory before shelling out.
3719
+ const requested = frameFiles.slice(0, CDP_MAX_FRAMES).filter((f) => typeof f === "string" && f);
3720
+ const existing = requested.filter((f) => { try { return fs.existsSync(f); } catch { return false; } });
3721
+ if (!existing.length) return { video: null, available: false, hint: "frame files are gone (nothing on disk to encode); re-run mode:record to capture frames" };
3722
+ const dir = path.dirname(existing[0]);
3723
+ if (!existing.every((f) => path.dirname(f) === dir)) return { video: null, available: false, hint: "frame files span multiple directories; cannot encode as one sequence" };
3724
+ const first = /-(\d+)\.jpg$/i.exec(path.basename(existing[0]));
3725
+ const startNumber = first ? String(parseInt(first[1], 10)) : "0";
3193
3726
  const video = path.join(dir, "motion.webm");
3194
- const args = ["-y", "-framerate", "2", "-i", path.join(dir, "frame-%02d.jpg"), "-c:v", "libvpx", "-pix_fmt", "yuv420p", video];
3727
+ const args = ["-y", "-framerate", "2", "-start_number", startNumber, "-i", path.join(dir, "frame-%02d.jpg"), "-c:v", "libvpx", "-pix_fmt", "yuv420p", video];
3195
3728
  const res = cp.spawnSync(bin, args, { timeout: 60000, windowsHide: true });
3196
- if (res && res.status === 0 && fs.existsSync(video)) return { video, available: true, bytes: fs.statSync(video).size };
3729
+ if (res && res.status === 0 && fs.existsSync(video)) return { video, available: true, bytes: fs.statSync(video).size, framesUsed: existing.length };
3197
3730
  return { video: null, available: false, hint: "ffmpeg encode failed (" + String(res && res.status) + "); frame JPEGs retained" };
3198
3731
  },
3199
3732
  };
@@ -3219,7 +3752,7 @@ function reduceCdpScan(url, scan, t, viewport) {
3219
3752
  scroll: { triggerCount: st.count || 0, scrubCount: st.scrub || 0, pinCount: st.pin || 0, scrubRatio: st.count ? (st.scrub || 0) / st.count : 0, sample: (st.sample || []).slice(0, 20) },
3220
3753
  easingVocab: [...agg.values()].sort((a, b) => b.uses - a.uses).slice(0, 15),
3221
3754
  durationVocab: ms.length ? { p25: q(0.25), median: q(0.5), p75: q(0.75) } : null,
3222
- capturedAt: t, toolVersion: "aura/1.7.2", source: "live-browser",
3755
+ capturedAt: t, toolVersion: "aura/1.7.3", source: "live-browser",
3223
3756
  };
3224
3757
  }
3225
3758
 
@@ -3783,10 +4316,11 @@ function createHandlers(catalog, icons, version, awardDeps) {
3783
4316
  const line = awardStaticSummary(d.slug, d.motion_static);
3784
4317
  if (line) staticCues.push(line);
3785
4318
  }
4319
+ let motionRecorded = false;
3786
4320
  try {
3787
4321
  const mr = awardIndex.searchMotion({ limit: 25 });
3788
4322
  const hits = (mr.items || []).filter((m) => normed.some((s) => String(m.url || '').toLowerCase().includes(s)));
3789
- if (hits.length) motionNotes = 'Recorded motion for ' + hits.map((h) => h.url).join(', ') + ': prefer ' + hits.map((h) => (h.stack && h.stack.libs || []).join('+') || 'css').join('; ');
4323
+ if (hits.length) { motionRecorded = true; motionNotes = 'Recorded motion for ' + hits.map((h) => h.url).join(', ') + ': prefer ' + hits.map((h) => (h.stack && h.stack.libs || []).join('+') || 'css').join('; '); }
3790
4324
  else {
3791
4325
  if (staticCues.length) motionNotes = 'Static markup cues (NOT runtime observation — record a study for trigger counts/easing): ' + staticCues.join(' | ');
3792
4326
  gaps.push('motion: no recorded studies for these refs — run aura_motion_study {mode:inspect} on their live URLs, or follow the hand-written <500ms + prefers-reduced-motion guidance');
@@ -3798,7 +4332,7 @@ function createHandlers(catalog, icons, version, awardDeps) {
3798
4332
  // PROPOSED tokens (never presented as observed ref evidence), mapped
3799
4333
  // against the refs' observed luminance bands.
3800
4334
  const requestedPalette = [...new Set([...String(goal + ' ' + (visualDirection || '')).matchAll(/#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/g)].map((m) => { const h = m[1]; return '#' + (h.length === 3 ? h.split('').map((c) => c + c).join('') : h).toUpperCase(); }))].slice(0, 6);
3801
- const built = awardBuildBlueprint({ goal, refs: normed, details, auraMatches, structureNotes, motionNotes, constraints, technology, visualDirection, responsive, requestedPalette, gaps });
4335
+ const built = awardBuildBlueprint({ goal, refs: normed, details, auraMatches, structureNotes, motionNotes, motionRecorded, constraints, technology, visualDirection, responsive, requestedPalette, gaps });
3802
4336
  // next_calls: immediately executable follow-ups built ONLY from real
3803
4337
  // ids/urls gathered above — the agent acts without another search round.
3804
4338
  const compMatches = auraMatches.filter((m) => m.section !== 'tokens' && !m.editorial).slice(0, 3);
@@ -3839,11 +4373,16 @@ function createHandlers(catalog, icons, version, awardDeps) {
3839
4373
  };
3840
4374
  const isLocalInput = (raw) => typeof raw === 'string' && (/^file:\/\//i.test(raw) || /^[A-Za-z]:[\\/]/.test(raw) || raw.startsWith('/') || /^[^/:?#]+\.html?([?#]|$)/i.test(raw));
3841
4375
  const fetchStatic = async (target) => {
3842
- const res = await globalThis.fetch(target, { headers: { 'user-agent': 'aura-components-mcp/1.7.2' }, redirect: 'follow', signal: AbortSignal.timeout(10000) });
3843
- if (res.status === 404) { const e = new Error('page not found: ' + target); e.code = 'NOT_FOUND'; throw e; }
3844
- if (!res.ok) throw awardErr('UPSTREAM_ERROR', 'HTTP ' + res.status + ' for ' + target);
3845
- const text = await res.text();
3846
- return text.slice(0, 1500000);
4376
+ // Shared guarded fetch: manual redirects with per-hop URL
4377
+ // classification + DNS guard, streaming bounded read. The old
4378
+ // redirect:'follow' fetch could land on a blocked host after a
4379
+ // server-side redirect; this one re-validates every hop.
4380
+ const got = await awardFetchGuardedHtml(target, { fetchImpl: globalThis.fetch, allowLocal, label: 'audited page' });
4381
+ if (!got.ok) {
4382
+ if (got.status === 404) { const e = new Error('page not found: ' + target); e.code = 'NOT_FOUND'; throw e; }
4383
+ throw awardErr('UPSTREAM_ERROR', 'static fetch failed for ' + target + ': ' + String(got.reason).slice(0, 200));
4384
+ }
4385
+ return got.html;
3847
4386
  };
3848
4387
  const structureOf = async (rawTarget, label) => {
3849
4388
  if (isLocalInput(rawTarget)) return readLocalSource(rawTarget, label || 'url');
@@ -3874,7 +4413,7 @@ function createHandlers(catalog, icons, version, awardDeps) {
3874
4413
  if (!awardBrowser) throw awardErr('BROWSER_UNAVAILABLE', 'capture needs Chrome: install it or set AURA_CHROME_PATH. Fallback: aura_visual_audit {mode:structure} estimates sections from static HTML without a browser.');
3875
4414
  try { return textResult(await awardBrowser.capture(target, { viewport, waitStrategy, settleMs, allowLocal })); }
3876
4415
  catch (e) {
3877
- if (e && e.code === 'BROWSER_UNAVAILABLE') throw e;
4416
+ if (e && (e.code === 'BROWSER_UNAVAILABLE' || e.code === 'UNSUPPORTED_OPERATION')) throw e;
3878
4417
  throw awardErr('UPSTREAM_ERROR', 'capture failed: ' + String((e && e.message) || e).slice(0, 220) + ' — retry, or use mode:structure for a static estimate');
3879
4418
  }
3880
4419
  }
@@ -3928,7 +4467,7 @@ function createHandlers(catalog, icons, version, awardDeps) {
3928
4467
  video = enc.video; videoAvailable = !!enc.available; videoHint = enc.hint || null;
3929
4468
  } catch (e) { videoHint = String((e && e.message) || e).slice(0, 160); }
3930
4469
  if (!videoAvailable && !videoHint) videoHint = 'ffmpeg not found; install ffmpeg or set AURA_FFMPEG_PATH for .webm output — frame JPEGs retained';
3931
- return textResult({ mode, url: target, study: rec.study, frames: rec.frames, frameCount: rec.frameCount, video, video_available: videoAvailable, ...(videoHint ? { videoHint } : {}) }); },
4470
+ return textResult({ mode, url: target, study: rec.study, frames: rec.frames, frameCount: rec.frameCount, video, video_available: videoAvailable, ...(videoHint ? { videoHint } : {}), ...(rec.retentionNote ? { retentionNote: rec.retentionNote } : {}) }); },
3932
4471
  aura_award_index: async (a) => { a = a || {}; const action = awardMode(a.action, ['status', 'rebuild', 'resume', 'refresh', 'new_winners', 'watch_add', 'watch_list', 'watch_remove'], 'action');
3933
4472
  if (action === 'status') return textResult({ action, ...awardIndex.getStatus() });
3934
4473
  if (action === 'watch_list') return textResult({ action, watches: awardIndex.watchList() });
@@ -3945,18 +4484,22 @@ function createHandlers(catalog, icons, version, awardDeps) {
3945
4484
  if (action === 'new_winners') {
3946
4485
  if (!awardClient) throw awardErr('UNSUPPORTED_OPERATION', 'new_winners needs an award client (server wiring)');
3947
4486
  const r = await awardIndex.newWinners({ client: awardClient, award: a.award || 'sotd' });
3948
- let deltas = [];
3949
- try { deltas = awardIndex.checkWatches ? awardIndex.checkWatches() : []; } catch { deltas = []; }
3950
- return textResult({ action, ...r, watchDeltas: deltas });
4487
+ let deltas = []; let seeded = [];
4488
+ try {
4489
+ if (awardIndex.checkWatchesDetailed) { const w = awardIndex.checkWatchesDetailed(); deltas = w.deltas || []; seeded = w.seeded || []; }
4490
+ else deltas = awardIndex.checkWatches ? awardIndex.checkWatches() : [];
4491
+ } catch { deltas = []; seeded = []; }
4492
+ return textResult({ action, ...r, watchDeltas: deltas, ...(seeded.length ? { watchBaselineSeeded: seeded, watchBaselineNote: 'first check for these watches seeded their baseline silently — future deltas report newly indexed matches only' } : {}) });
3951
4493
  }
3952
4494
  if (!awardClient) throw awardErr('UNSUPPORTED_OPERATION', action + ' needs an award client (server wiring)');
3953
4495
  const maxPages = a.maxPages === undefined ? 5 : toolFn_num(a.maxPages, 'maxPages');
3954
4496
  if (typeof maxPages !== 'number' || maxPages < 1 || maxPages > 20) toolFn_bad('maxPages must be 1-20 (bounded crawl per call; resume for more)');
3955
- // Bounded crawl: cap the taxonomy to maxPages per call, keep resume state.
3956
- const tax = awardIndex.getTaxonomy();
3957
- const tags = (tax.filters || []).slice(0, Math.floor(maxPages));
3958
- const r = action === 'refresh' ? await awardIndex.refresh({ client: awardClient, tags }) : action === 'resume' ? await awardIndex.resume({ client: awardClient, tags: tags.length ? tags : undefined }) : await awardIndex.rebuild({ client: awardClient, tags: tags.length ? tags : undefined });
3959
- return textResult({ action, ...r, note: 'bounded per call (maxPages); call resume to continue' }); },
4497
+ // Bounded crawl: the index layer owns the plan; maxPages caps fetches
4498
+ // THIS call so a plan advances across calls instead of re-crawling the
4499
+ // same prefix. No tag slicing here — the stored plan defines the pages.
4500
+ const args = { client: awardClient, maxPages: Math.floor(maxPages) };
4501
+ const r = action === 'refresh' ? await awardIndex.refresh(args) : action === 'resume' ? await awardIndex.resume(args) : await awardIndex.rebuild(args);
4502
+ return textResult({ action, ...r, note: r.note || 'page budget reached; call resume to continue the same plan' }); },
3960
4503
  };
3961
4504
  };
3962
4505