wiki-plugin-similarity 0.4.4 → 0.5.0

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.
@@ -1,6 +1,6 @@
1
- /* wiki-plugin-similarity - 0.4.4 - Tue, 07 Jul 2026 19:36:29 GMT */
2
- (()=>{var F={high:.78,medium:.68,low:.58},O=F.medium,q=10,N=t=>{let e=[],i=null,s=null,o="search",x=!1,y=!1,v=null,b=null,p=(n,c)=>n===c||n.startsWith(c)&&/^[\s:]/.test(n.slice(c.length)),w=(n,c)=>n.slice(c.length).replace(/^\s*:?\s*/,"").trim();for(let n of t.split(`
3
- `)){let c=n.trim();if(!c||c.startsWith("#"))continue;let h=c.toUpperCase();if(p(h,"LIVE")){x=!0;continue}if(p(h,"AUTHOR")){!e.length&&o==="search"&&(o="author");continue}if(p(h,"REPORT")){o==="search"&&(o="report");continue}if(p(h,"BUILD")){o==="search"&&(o="build");continue}if(p(h,"FORCE")){y=!0;continue}if(p(h,"GHOST")){v=w(c,"GHOST"),o==="search"&&(o="ghost");continue}if(p(h,"BUTTON")){b=w(c,"BUTTON");continue}if(p(h,"LIST")){!e.length&&o==="search"&&(o="list");continue}if(p(h,"SIMILAR")){let k=w(h,"SIMILAR").toLowerCase();i=F[k]||O,!e.length&&o==="search"&&(o="similar");continue}if(p(h,"THRESHOLD")){let k=w(c,"THRESHOLD");i=F[k.toLowerCase()]??(parseFloat(k)||O);continue}if(p(h,"LIMIT")){s=parseInt(w(c,"LIMIT"))||q;continue}e.push(["PUBLIC","LOCAL","PRIVATE"].includes(h)?h:c)}return{mode:o,specs:e,threshold:i??O,limit:s??q,live:x,force:y,ghostUrl:v,label:b,thresholdSet:i!==null}},Y=t=>t.includes("*")||t.includes("?"),Q=t=>t==="PUBLIC"||t==="LOCAL"||t==="PRIVATE",X=t=>t.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,""),M=new Map,Z=async(t,e)=>{if(M.has(t))return M.get(t);let i=`${e}/system/indexed-domains.json?pattern=${encodeURIComponent(t)}`,s=await fetch(i);if(!s.ok)throw new Error(`indexed-domains failed: ${s.status}`);let o=await s.json();return M.set(t,o),o},B=async(t,e)=>{t.length||(t=[window.location.hostname]);let i=new Set,s=[];for(let o of t)if(o==="*"||Y(o)||Q(o))for(let x of await Z(o,e))i.has(x.domain)||(i.add(x.domain),s.push(x));else i.has(o)||(i.add(o),s.push({domain:o,page_count:null}));return s},U=new Map,tt=t=>`${window.location.origin}/system/semantic-vectors.json?domain=${encodeURIComponent(t)}`,V=async t=>{if(U.has(t))return U.get(t);let e=await fetch(tt(t));if(!e.ok)return[];let i=await e.json();return U.set(t,i),i},H=async(t,e)=>{let i=await fetch(`${e}/system/embed.json?text=${encodeURIComponent(t)}`);if(!i.ok)throw new Error(`embed failed: ${i.status}`);return(await i.json()).vector},et=async(t,e)=>{let s=(await V(e)).find(o=>o.slug===t);return s?s.vector:null},P=(t,e,{threshold:i,limit:s,excludeSlug:o,excludeDomain:x})=>{let y=[];for(let{domain:v,pages:b}of e)for(let{slug:p,title:w,vector:n}of b){if(p===o&&v===x)continue;let c=0;for(let h=0;h<t.length;h++)c+=t[h]*n[h];c>=i&&y.push({domain:v,slug:p,title:w,score:c})}return y.sort((v,b)=>b.score-v.score),y.slice(0,s)},A=async(t,e)=>{let i=await B(t,e);return(await Promise.all(i.map(async({domain:o})=>({domain:o,pages:await V(o)})))).filter(o=>o.pages.length>0)},G=t=>`sim-cache-${t}`,st=t=>{try{let e=JSON.parse(localStorage.getItem(G(t.id))||"null");return e?.text===(t.text||"")?e:null}catch{return null}},I=(t,e)=>{try{localStorage.setItem(G(t.id),JSON.stringify({text:t.text||"",ts:Date.now(),...e}))}catch{}},nt=t=>{let e=Math.floor((Date.now()-t)/1e3);return e<60?`${e}s ago`:e<3600?`${Math.floor(e/60)}m ago`:e<86400?`${Math.floor(e/3600)}h ago`:`${Math.floor(e/86400)}d ago`},j=`
1
+ /* wiki-plugin-similarity - 0.5.0 - Sat, 11 Jul 2026 12:35:40 GMT */
2
+ (()=>{var z={high:.78,medium:.68,low:.58},O=z.medium,F=10,N=t=>{let n=[],a=null,e=null,r="search",x=!1,y=!1,C=null,b=null,h=(s,d)=>s===d||s.startsWith(d)&&/^[\s:]/.test(s.slice(d.length)),w=(s,d)=>s.slice(d.length).replace(/^\s*:?\s*/,"").trim();for(let s of t.split(`
3
+ `)){let d=s.trim();if(!d||d.startsWith("#"))continue;let g=d.toUpperCase();if(h(g,"LIVE")){x=!0;continue}if(h(g,"AUTHOR")){!n.length&&r==="search"&&(r="author");continue}if(h(g,"REPORT")){r==="search"&&(r="report");continue}if(h(g,"KEYWORD")){r==="search"&&(r="keyword");continue}if(h(g,"BUILD")){r==="search"&&(r="build");continue}if(h(g,"FORCE")){y=!0;continue}if(h(g,"GHOST")){C=w(d,"GHOST"),r==="search"&&(r="ghost");continue}if(h(g,"BUTTON")){b=w(d,"BUTTON");continue}if(h(g,"LIST")){!n.length&&r==="search"&&(r="list");continue}if(h(g,"SIMILAR")){let S=w(g,"SIMILAR").toLowerCase();a=z[S]||O,!n.length&&r==="search"&&(r="similar");continue}if(h(g,"THRESHOLD")){let S=w(d,"THRESHOLD");a=z[S.toLowerCase()]??(parseFloat(S)||O);continue}if(h(g,"LIMIT")){e=parseInt(w(d,"LIMIT"))||F;continue}n.push(["PUBLIC","LOCAL","PRIVATE"].includes(g)?g:d)}return{mode:r,specs:n,threshold:a??O,limit:e??F,live:x,force:y,ghostUrl:C,label:b,thresholdSet:a!==null}},Y=t=>t.includes("*")||t.includes("?"),Q=t=>t==="PUBLIC"||t==="LOCAL"||t==="PRIVATE",X=t=>t.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,""),U=new Map,Z=async(t,n)=>{if(U.has(t))return U.get(t);let a=`${n}/system/indexed-domains.json?pattern=${encodeURIComponent(t)}`,e=await fetch(a);if(!e.ok)throw new Error(`indexed-domains failed: ${e.status}`);let r=await e.json();return U.set(t,r),r},B=async(t,n)=>{t.length||(t=[window.location.hostname]);let a=new Set,e=[];for(let r of t)if(r==="*"||Y(r)||Q(r))for(let x of await Z(r,n))a.has(x.domain)||(a.add(x.domain),e.push(x));else a.has(r)||(a.add(r),e.push({domain:r,page_count:null}));return e},M=new Map,tt=t=>`${window.location.origin}/system/semantic-vectors.json?domain=${encodeURIComponent(t)}`,V=async t=>{if(M.has(t))return M.get(t);let n=await fetch(tt(t));if(!n.ok)return[];let a=await n.json();return M.set(t,a),a},H=async(t,n)=>{let a=await fetch(`${n}/system/embed.json?text=${encodeURIComponent(t)}`);if(!a.ok)throw new Error(`embed failed: ${a.status}`);return(await a.json()).vector},et=async(t,n)=>{let e=(await V(n)).find(r=>r.slug===t);return e?e.vector:null},P=(t,n,{threshold:a,limit:e,excludeSlug:r,excludeDomain:x})=>{let y=[];for(let{domain:C,pages:b}of n)for(let{slug:h,title:w,vector:s}of b){if(h===r&&C===x)continue;let d=0;for(let g=0;g<t.length;g++)d+=t[g]*s[g];d>=a&&y.push({domain:C,slug:h,title:w,score:d})}return y.sort((C,b)=>b.score-C.score),y.slice(0,e)},A=async(t,n)=>{let a=await B(t,n);return(await Promise.all(a.map(async({domain:r})=>({domain:r,pages:await V(r)})))).filter(r=>r.pages.length>0)},K=t=>`sim-cache-${t}`,nt=t=>{try{let n=JSON.parse(localStorage.getItem(K(t.id))||"null");return n?.text===(t.text||"")?n:null}catch{return null}},R=(t,n)=>{try{localStorage.setItem(K(t.id),JSON.stringify({text:t.text||"",ts:Date.now(),...n}))}catch{}},st=t=>{let n=Math.floor((Date.now()-t)/1e3);return n<60?`${n}s ago`:n<3600?`${Math.floor(n/60)}m ago`:n<86400?`${Math.floor(n/3600)}h ago`:`${Math.floor(n/86400)}d ago`},T=`
4
4
  .sim-form { display:flex; gap:6px; margin-bottom:8px; }
5
5
  .sim-input { flex:1; padding:6px 8px; font-size:14px; border:1px solid #ccc; border-radius:3px; }
6
6
  .sim-btn { padding:6px 14px; background:#c4561d; color:white; border:none;
@@ -24,63 +24,63 @@
24
24
  .sim-list td { padding:3px 8px 3px 0; border-bottom:1px solid #f0f0f0; vertical-align:middle; }
25
25
  .sim-list td:last-child { text-align:right; color:#999; font-size:11px; }
26
26
  .sim-list .sim-flag { margin-right:6px; }
27
- `,it=(t,e)=>`<img class="sim-flag remote" src="${window.wiki.site(t).flag()}"
28
- title="${t} \u2014 score ${e.toFixed(3)}"
29
- data-site="${t}">`,z=(t,e,i,s)=>`<a class="sim-link" data-title="${i}" data-slug="${e}" data-site="${t}" href="#">${it(t,s)} ${i}</a>`,ot=(t,e)=>{let{mode:i,specs:s,threshold:o,limit:x,force:y,ghostUrl:v,label:b}=N(e?.text||"");if(i==="ghost")t.html(`
30
- <style>${j}</style>
31
- <div class="similarity" data-id="${e.id}">
27
+ `,it=(t,n)=>`<img class="sim-flag remote" src="${window.wiki.site(t).flag()}"
28
+ title="${t} \u2014 score ${n.toFixed(3)}"
29
+ data-site="${t}">`,q=(t,n,a,e)=>`<a class="sim-link" data-title="${a}" data-slug="${n}" data-site="${t}" href="#">${it(t,e)} ${a}</a>`,ot=(t,n)=>{let{mode:a,specs:e,threshold:r,limit:x,force:y,ghostUrl:C,label:b}=N(n?.text||"");if(a==="ghost")t.html(`
30
+ <style>${T}</style>
31
+ <div class="similarity" data-id="${n.id}">
32
32
  <div class="sim-form">
33
33
  <button class="sim-btn">${b||"Open"}</button>
34
34
  </div>
35
35
  <div class="sim-status"></div>
36
- </div>`);else if(i==="build")t.html(`
37
- <style>${j}</style>
38
- <div class="similarity" data-id="${e.id}">
36
+ </div>`);else if(a==="build")t.html(`
37
+ <style>${T}</style>
38
+ <div class="similarity" data-id="${n.id}">
39
39
  <div class="sim-form">
40
- <button class="sim-btn">${b||`Index ${s.length?s.join(", "):"*"}${y?" (force)":""}`}</button>
40
+ <button class="sim-btn">${b||`Index ${e.length?e.join(", "):"*"}${y?" (force)":""}`}</button>
41
41
  </div>
42
42
  <div class="sim-status"></div>
43
- </div>`);else if(i==="list"){let p=s.length?s.join(", "):"*";t.html(`
44
- <style>${j}</style>
45
- <div class="similarity" data-id="${e.id}">
46
- <div class="sim-status">Loading indexed domains (${p})\u2026</div>
43
+ </div>`);else if(a==="list"){let h=e.length?e.join(", "):"*";t.html(`
44
+ <style>${T}</style>
45
+ <div class="similarity" data-id="${n.id}">
46
+ <div class="sim-status">Loading indexed domains (${h})\u2026</div>
47
47
  <div class="sim-list"></div>
48
- </div>`)}else if(i==="similar"){let p=s.length?s.join(", "):"current domain";t.html(`
49
- <style>${j}</style>
50
- <div class="similarity" data-id="${e.id}">
51
- <div class="sim-status">Finding similar pages across ${p}\u2026</div>
48
+ </div>`)}else if(a==="similar"){let h=e.length?e.join(", "):"current domain";t.html(`
49
+ <style>${T}</style>
50
+ <div class="similarity" data-id="${n.id}">
51
+ <div class="sim-status">Finding similar pages across ${h}\u2026</div>
52
52
  <div class="sim-results"></div>
53
- </div>`)}else if(i==="author"||i==="report"){let p=s.length?s.join(", "):"(current domain)",w=i==="report"?"Report":"Author";t.html(`
54
- <style>${j}</style>
55
- <div class="similarity" data-id="${e.id}">
53
+ </div>`)}else if(a==="author"||a==="report"||a==="keyword"){let h=e.length?e.join(", "):"(current domain)",w=a==="report"?"Report":a==="keyword"?"Keyword":"Author";t.html(`
54
+ <style>${T}</style>
55
+ <div class="similarity" data-id="${n.id}">
56
56
  <div class="sim-form">
57
57
  <input class="sim-input" type="text" placeholder="Search wiki pages\u2026" />
58
58
  <button class="sim-btn">${w}</button>
59
59
  </div>
60
- <div class="sim-status">Domains: ${p}</div>
60
+ <div class="sim-status">Domains: ${h}</div>
61
61
  <div class="sim-results"></div>
62
- </div>`)}else{let p=s.length?s.join(", "):"(current domain)";t.html(`
63
- <style>${j}</style>
64
- <div class="similarity" data-id="${e.id}">
62
+ </div>`)}else{let h=e.length?e.join(", "):"(current domain)";t.html(`
63
+ <style>${T}</style>
64
+ <div class="similarity" data-id="${n.id}">
65
65
  <div class="sim-form">
66
66
  <input class="sim-input" type="text" placeholder="Search wiki pages\u2026" />
67
67
  <button class="sim-btn">Search</button>
68
68
  </div>
69
- <div class="sim-status">Domains: ${p}</div>
69
+ <div class="sim-status">Domains: ${h}</div>
70
70
  <div class="sim-results"></div>
71
- </div>`)}},at=(t,e)=>{let{mode:i,specs:s,threshold:o,limit:x,live:y,force:v,ghostUrl:b,thresholdSet:p}=N(e?.text||""),w=window.location.origin,n=t.find(".sim-status")[0],c=y?null:st(e),h=(d,g,r)=>{let l=[`${d} \u2014 ${g.toLocaleString()} pages across ${r} domains`];return p&&l.push(`threshold ${o}`),l.push(`limit ${x}`),y&&l.push("LIVE"),l.join(" \xB7 ")};t.on("dblclick",d=>{$(d.target).closest(".sim-input").length||window.wiki.textEditor(t,e)}),t.on("click",".sim-link",function(d){d.preventDefault();let g=$(this),r=d.shiftKey?null:t.parents(".page");window.wiki.doInternalLink(g.data("title"),r,g.data("site"))});let k=!s.length||s.length===1&&s[0]==="*"?"on farm":s.length===1?`on ${s[0]}`:"in domains",R=d=>d?` \xB7 cached ${nt(d)}`:"";if(i==="list"){let d=t.find(".sim-list")[0],g=s.length?s.join(","):"*",r=(l,m)=>{let a=l.reduce((u,f)=>u+(f.page_count||0),0);n.style.display="none",d.innerHTML=`<h3>Indexed Farm Domains</h3>
71
+ </div>`)}},at=(t,n)=>{let{mode:a,specs:e,threshold:r,limit:x,live:y,force:C,ghostUrl:b,thresholdSet:h}=N(n?.text||""),w=window.location.origin,s=t.find(".sim-status")[0],d=y?null:nt(n),g=(c,f,l)=>{let o=[`${c} \u2014 ${f.toLocaleString()} pages across ${l} domains`];return h&&o.push(`threshold ${r}`),o.push(`limit ${x}`),y&&o.push("LIVE"),o.join(" \xB7 ")};t.on("dblclick",c=>{$(c.target).closest(".sim-input").length||window.wiki.textEditor(t,n)}),t.on("click",".sim-link",function(c){c.preventDefault();let f=$(this),l=c.shiftKey?null:t.parents(".page");window.wiki.doInternalLink(f.data("title"),l,f.data("site"))});let S=!e.length||e.length===1&&e[0]==="*"?"on farm":e.length===1?`on ${e[0]}`:"in domains",I=c=>c?` \xB7 cached ${st(c)}`:"";if(a==="list"){let c=t.find(".sim-list")[0],f=e.length?e.join(","):"*",l=(o,m)=>{let i=o.reduce((u,p)=>u+(p.page_count||0),0);s.style.display="none",c.innerHTML=`<h3>Indexed Farm Domains</h3>
72
72
  <table>
73
73
  <tr><th>Domain</th><th>Pages</th></tr>
74
- ${l.map(({domain:u,page_count:f})=>`
74
+ ${o.map(({domain:u,page_count:p})=>`
75
75
  <tr>
76
76
  <td><img class="sim-flag remote" src="${window.wiki.site(u).flag()}"
77
77
  title="${u}" data-site="${u}"> ${u}</td>
78
- <td>${f!=null?f.toLocaleString():"\u2014"}</td>
78
+ <td>${p!=null?p.toLocaleString():"\u2014"}</td>
79
79
  </tr>`).join("")}
80
80
  </table>
81
- <p class="sim-count">${l.length} domains \u2014 ${a.toLocaleString()} pages${R(m)}</p>`};c?.domains?r(c.domains,c.ts):(async()=>{try{let l=`${w}/system/indexed-domains.json?pattern=${encodeURIComponent(g)}&limit=${x}`,m=await fetch(l);if(!m.ok)throw new Error(`indexed-domains failed: ${m.status}`);let a=await m.json();if(!a.length){n.textContent="No indexed domains found";return}r(a,null),I(e,{domains:a})}catch(l){n.textContent=`Error: ${l.message}`}})()}else if(i==="similar"){let d=t.find(".sim-results")[0],g=(r,l)=>{if(!r.length){n.textContent=`No similar pages found above threshold ${o}`;return}d.innerHTML=`<h3>Similar Pages</h3><ul>${r.map(({domain:m,slug:a,title:u,score:f})=>`<li>${z(m,a,u,f)}</li>`).join("")}</ul><p class="sim-count">${r.length} found ${k}${R(l)}</p>`,n.style.display="none"};c?.scored?g(c.scored,c.ts):(async()=>{try{let r=t.parents(".page"),l=r.find(".title").text().trim()||document.title,m=X(l),a=window.location.hostname,u=await A(s,w),f=u.reduce((C,T)=>C+T.pages.length,0);n.textContent=`Searching ${f.toLocaleString()} pages\u2026`;let L=await et(m,a);if(!L){n.textContent="Embedding page (not yet indexed)\u2026";let C=r.find(".item").map((T,D)=>$(D).text().trim()).get().filter(Boolean).join(`
82
- `);L=await H(C||l,w)}let S=P(L,u,{threshold:o,limit:x,excludeSlug:m,excludeDomain:a});g(S,null),S.length&&I(e,{scored:S})}catch(r){n.textContent=`Error: ${r.message}`}})()}else if(i==="ghost"){let d=t.find(".sim-btn")[0],g=async()=>{if(!b){n.textContent="No URL \u2014 GHOST needs a page-json URL";return}d.disabled=!0,n.textContent="Fetching\u2026";try{let r=await fetch(b);if(!r.ok)throw new Error(`fetch failed: ${r.status}`);let l=await r.json();window.wiki.showResult(window.wiki.newPage(l),{$page:t.parents(".page")}),n.textContent=""}catch(r){n.textContent=`Error: ${r.message}`}finally{d.disabled=!1}};d.addEventListener("click",g)}else if(i==="build"){let d=t.find(".sim-btn")[0],g=async()=>{d.disabled=!0,n.textContent="Building index\u2026 (may take a while for large scopes)";try{let r=encodeURIComponent((s.length?s:["*"]).join(",")),l=await fetch(`http://api.localhost/build-index.json?domains=${r}&force=${v?1:0}`);if(!l.ok)throw new Error(`build-index failed: ${l.status}`);let m=await l.json();window.wiki.showResult(window.wiki.newPage(m),{$page:t.parents(".page")}),n.textContent=""}catch(r){n.textContent=`Error: ${r.message}`}finally{d.disabled=!1}};d.addEventListener("click",g)}else if(i==="report"){let d=t.find(".sim-input")[0],g=t.find(".sim-btn")[0],r=`Domains: ${s.length?s.join(", "):"*"}`;(async()=>{try{let m=await B(s.length?s:["*"],w),a=m.reduce((u,f)=>u+(f.page_count||0),0);r=h("Report ready",a,m.length),n.textContent=r}catch(m){n.textContent=`Domain listing unavailable: ${m.message}`}})();let l=async()=>{let m=d.value.trim();if(m){g.disabled=!0,n.textContent="Generating report\u2026";try{let a={query:m,domains:s.length?s:["*"],limit:x,live:y};p&&(a.threshold=o);let u=await fetch("http://api.localhost/search-report",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)});if(!u.ok)throw new Error(`search-report failed: ${u.status}`);let f=await u.json(),L=window.wiki.newPage(f);window.wiki.showResult(L,{$page:t.parents(".page")}),n.textContent=r}catch(a){n.textContent=`Error: ${a.message}`}finally{g.disabled=!1}}};g.addEventListener("click",l),d.addEventListener("keydown",m=>{m.key==="Enter"&&l()})}else if(i==="author"){let d=t.find(".sim-input")[0],g=t.find(".sim-btn")[0],r=t.find(".sim-results")[0],l=null;(async()=>{try{c||(n.textContent="Resolving domains\u2026"),l=await A(s,w);let a=l.reduce((u,f)=>u+f.pages.length,0);n.textContent=h("Author ready",a,l.length)}catch(a){n.textContent=`Load error: ${a.message}`}})();let m=async()=>{let a=d.value.trim();if(!(!a||!l)){g.disabled=!0,n.textContent="Embedding query\u2026",r.innerHTML="";try{let u=await H(a,w),f=P(u,l,{threshold:o,limit:x,excludeSlug:null,excludeDomain:null}),L=new Set,S=[];for(let{title:E}of f)L.has(E)||(L.add(E),S.push(E));let C=()=>Math.floor(Math.random()*18446744073709552e3).toString(16).padStart(16,"0"),T=S.map(E=>`- [[${E}]]`).join(`
83
- `),D=[{type:"markdown",id:C(),text:`# Similar Pages
81
+ <p class="sim-count">${o.length} domains \u2014 ${i.toLocaleString()} pages${I(m)}</p>`};d?.domains?l(d.domains,d.ts):(async()=>{try{let o=`${w}/system/indexed-domains.json?pattern=${encodeURIComponent(f)}&limit=${x}`,m=await fetch(o);if(!m.ok)throw new Error(`indexed-domains failed: ${m.status}`);let i=await m.json();if(!i.length){s.textContent="No indexed domains found";return}l(i,null),R(n,{domains:i})}catch(o){s.textContent=`Error: ${o.message}`}})()}else if(a==="similar"){let c=t.find(".sim-results")[0],f=(l,o)=>{if(!l.length){s.textContent=`No similar pages found above threshold ${r}`;return}c.innerHTML=`<h3>Similar Pages</h3><ul>${l.map(({domain:m,slug:i,title:u,score:p})=>`<li>${q(m,i,u,p)}</li>`).join("")}</ul><p class="sim-count">${l.length} found ${S}${I(o)}</p>`,s.style.display="none"};d?.scored?f(d.scored,d.ts):(async()=>{try{let l=t.parents(".page"),o=l.find(".title").text().trim()||document.title,m=X(o),i=window.location.hostname,u=await A(e,w),p=u.reduce((k,j)=>k+j.pages.length,0);s.textContent=`Searching ${p.toLocaleString()} pages\u2026`;let L=await et(m,i);if(!L){s.textContent="Embedding page (not yet indexed)\u2026";let k=l.find(".item").map((j,D)=>$(D).text().trim()).get().filter(Boolean).join(`
82
+ `);L=await H(k||o,w)}let v=P(L,u,{threshold:r,limit:x,excludeSlug:m,excludeDomain:i});f(v,null),v.length&&R(n,{scored:v})}catch(l){s.textContent=`Error: ${l.message}`}})()}else if(a==="ghost"){let c=t.find(".sim-btn")[0],f=async()=>{if(!b){s.textContent="No URL \u2014 GHOST needs a page-json URL";return}c.disabled=!0,s.textContent="Fetching\u2026";try{let l=await fetch(b);if(!l.ok)throw new Error(`fetch failed: ${l.status}`);let o=await l.json();window.wiki.showResult(window.wiki.newPage(o),{$page:t.parents(".page")}),s.textContent=""}catch(l){s.textContent=`Error: ${l.message}`}finally{c.disabled=!1}};c.addEventListener("click",f)}else if(a==="build"){let c=t.find(".sim-btn")[0],f=async()=>{c.disabled=!0,s.textContent="Building index\u2026 (may take a while for large scopes)";try{let l=encodeURIComponent((e.length?e:["*"]).join(",")),o=await fetch(`${w}/system/build-index.json?domains=${l}&force=${C?1:0}`);if(o.status===501){let i=await o.json().catch(()=>({}));throw new Error(i.hint||"indexing runs on the farm indexer, not this server")}if(!o.ok)throw new Error(`build-index failed: ${o.status}`);let m=await o.json();window.wiki.showResult(window.wiki.newPage(m),{$page:t.parents(".page")}),s.textContent=""}catch(l){s.textContent=`Error: ${l.message}`}finally{c.disabled=!1}};c.addEventListener("click",f)}else if(a==="report"){let c=t.find(".sim-input")[0],f=t.find(".sim-btn")[0],l=`Domains: ${e.length?e.join(", "):"*"}`;(async()=>{try{let m=await B(e.length?e:["*"],w),i=m.reduce((u,p)=>u+(p.page_count||0),0);l=g("Report ready",i,m.length),s.textContent=l}catch(m){s.textContent=`Domain listing unavailable: ${m.message}`}})();let o=async()=>{let m=c.value.trim();if(m){f.disabled=!0,s.textContent="Generating report\u2026";try{let i={query:m,domains:e.length?e:["*"],limit:x,live:y};h&&(i.threshold=r);let u=await fetch(`${w}/system/search-report.json`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)});if(!u.ok)throw new Error(`search-report failed: ${u.status}`);let p=await u.json(),L=window.wiki.newPage(p);window.wiki.showResult(L,{$page:t.parents(".page")}),s.textContent=l}catch(i){s.textContent=`Error: ${i.message}`}finally{f.disabled=!1}}};f.addEventListener("click",o),c.addEventListener("keydown",m=>{m.key==="Enter"&&o()})}else if(a==="keyword"){let c=t.find(".sim-input")[0],f=t.find(".sim-btn")[0];s.textContent=`Keyword search ready \u2014 domains: ${e.length?e.join(", "):"*"} \xB7 limit ${x}`;let l=async()=>{let o=c.value.trim();if(o){f.disabled=!0,s.textContent="Searching live site indexes\u2026";try{let m=encodeURIComponent((e.length?e:["*"]).join(",")),i=await fetch(`${w}/system/farm-search.json?q=${encodeURIComponent(o)}&pattern=${m}&limit=${x}`);if(!i.ok)throw new Error(`farm-search failed: ${i.status}`);let u=await i.json();window.wiki.showResult(window.wiki.newPage(u),{$page:t.parents(".page")}),s.textContent=`Keyword search ready \u2014 domains: ${e.length?e.join(", "):"*"} \xB7 limit ${x}`}catch(m){s.textContent=`Error: ${m.message}`}finally{f.disabled=!1}}};f.addEventListener("click",l),c.addEventListener("keydown",o=>{o.key==="Enter"&&l()})}else if(a==="author"){let c=t.find(".sim-input")[0],f=t.find(".sim-btn")[0],l=t.find(".sim-results")[0],o=null;(async()=>{try{d||(s.textContent="Resolving domains\u2026"),o=await A(e,w);let i=o.reduce((u,p)=>u+p.pages.length,0);s.textContent=g("Author ready",i,o.length)}catch(i){s.textContent=`Load error: ${i.message}`}})();let m=async()=>{let i=c.value.trim();if(!(!i||!o)){f.disabled=!0,s.textContent="Embedding query\u2026",l.innerHTML="";try{let u=await H(i,w),p=P(u,o,{threshold:r,limit:x,excludeSlug:null,excludeDomain:null}),L=new Set,v=[];for(let{title:E}of p)L.has(E)||(L.add(E),v.push(E));let k=()=>Math.floor(Math.random()*18446744073709552e3).toString(16).padStart(16,"0"),j=v.map(E=>`- [[${E}]]`).join(`
83
+ `),D=[{type:"markdown",id:k(),text:`# Similar Pages
84
84
 
85
- ${T}`},{type:"markdown",id:C(),text:"# Reference Links"},...f.map(({domain:E,slug:J,title:K,score:W})=>({type:"reference",id:C(),site:E,slug:J,title:K,text:`score ${W.toFixed(3)}`}))],_=window.wiki.newPage({title:`${a} Results`,story:D,journal:[]});window.wiki.showResult(_,{$page:t.parents(".page")}),n.textContent=`${f.length} pages found`,I(e,{scored:f,query:a})}catch(u){n.textContent=`Error: ${u.message}`}finally{g.disabled=!1}}};g.addEventListener("click",m),d.addEventListener("keydown",a=>{a.key==="Enter"&&m()})}else{let d=t.find(".sim-input")[0],g=t.find(".sim-btn")[0],r=t.find(".sim-results")[0],l=null;c?.scored&&(d.value=c.query||"",r.innerHTML=c.scored.map(({domain:a,slug:u,title:f,score:L})=>`<div class="sim-result">${z(a,u,f,L)}</div>`).join("")+`<p class="sim-count">Top ${c.scored.length} for "${c.query||""}"${R(c.ts)}</p>`,n.textContent=""),(async()=>{try{c||(n.textContent="Resolving domains\u2026"),l=await A(s,w);let a=l.reduce((u,f)=>u+f.pages.length,0);n.textContent=h("Search ready",a,l.length)}catch(a){n.textContent=`Load error: ${a.message}`}})();let m=async()=>{let a=d.value.trim();if(!(!a||!l)){g.disabled=!0,n.textContent="Embedding query\u2026",r.innerHTML="";try{let u=await H(a,w),f=P(u,l,{threshold:o,limit:x,excludeSlug:null,excludeDomain:null});r.innerHTML=f.map(({domain:L,slug:S,title:C,score:T})=>`<div class="sim-result">${z(L,S,C,T)}</div>`).join("")+`<p class="sim-count">Top ${f.length} for "${a}"</p>`,n.textContent="",I(e,{scored:f,query:a})}catch(u){n.textContent=`Error: ${u.message}`}finally{g.disabled=!1}}};g.addEventListener("click",m),d.addEventListener("keydown",a=>{a.key==="Enter"&&m()})}};typeof window<"u"&&(window.plugins=window.plugins||{},window.plugins.similarity={emit:ot,bind:at});})();
85
+ ${j}`},{type:"markdown",id:k(),text:"# Reference Links"},...p.map(({domain:E,slug:_,title:J,score:W})=>({type:"reference",id:k(),site:E,slug:_,title:J,text:`score ${W.toFixed(3)}`}))],G=window.wiki.newPage({title:`${i} Results`,story:D,journal:[]});window.wiki.showResult(G,{$page:t.parents(".page")}),s.textContent=`${p.length} pages found`,R(n,{scored:p,query:i})}catch(u){s.textContent=`Error: ${u.message}`}finally{f.disabled=!1}}};f.addEventListener("click",m),c.addEventListener("keydown",i=>{i.key==="Enter"&&m()})}else{let c=t.find(".sim-input")[0],f=t.find(".sim-btn")[0],l=t.find(".sim-results")[0],o=null;d?.scored&&(c.value=d.query||"",l.innerHTML=d.scored.map(({domain:i,slug:u,title:p,score:L})=>`<div class="sim-result">${q(i,u,p,L)}</div>`).join("")+`<p class="sim-count">Top ${d.scored.length} for "${d.query||""}"${I(d.ts)}</p>`,s.textContent=""),(async()=>{try{d||(s.textContent="Resolving domains\u2026"),o=await A(e,w);let i=o.reduce((u,p)=>u+p.pages.length,0);s.textContent=g("Search ready",i,o.length)}catch(i){s.textContent=`Load error: ${i.message}`}})();let m=async()=>{let i=c.value.trim();if(!(!i||!o)){f.disabled=!0,s.textContent="Embedding query\u2026",l.innerHTML="";try{let u=await H(i,w),p=P(u,o,{threshold:r,limit:x,excludeSlug:null,excludeDomain:null});l.innerHTML=p.map(({domain:L,slug:v,title:k,score:j})=>`<div class="sim-result">${q(L,v,k,j)}</div>`).join("")+`<p class="sim-count">Top ${p.length} for "${i}"</p>`,s.textContent="",R(n,{scored:p,query:i})}catch(u){s.textContent=`Error: ${u.message}`}finally{f.disabled=!1}}};f.addEventListener("click",m),c.addEventListener("keydown",i=>{i.key==="Enter"&&m()})}};typeof window<"u"&&(window.plugins=window.plugins||{},window.plugins.similarity={emit:ot,bind:at});})();
86
86
  //# sourceMappingURL=similarity.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/client/similarity.js"],
4
- "sourcesContent": ["// wiki-plugin-similarity\n//\n// One item type: similarity\n//\n// DSL in item.text (newline-separated lines):\n// * all indexed domains on this server\n// david.* glob pattern\n// *.fish glob pattern\n// david.hitchhikers.earth explicit domain name\n// # comment ignored\n// LIST list indexed domains (mode directive)\n// SIMILAR: high threshold preset AND ambient mode trigger\n// THRESHOLD: 0.72 exact cosine threshold (overrides SIMILAR:)\n// LIMIT: 8 max results shown (default 10)\n//\n// Mode is determined by the FIRST meaningful line (Ward's ALL-CAPS convention):\n// LIST \u2192 show a table of all indexed domains and their page counts;\n// optional glob patterns on subsequent lines filter the list\n// SIMILAR: \u2192 ambient mode \u2014 automatically find pages similar to this page\n// (other) \u2192 search form mode \u2014 user types a query, results appear\n//\n// Server endpoints required:\n// GET /system/indexed-domains.json?pattern=glob1,glob2\n// GET /system/semantic-vectors.json?domain=\n// GET /system/embed.json?text=query\n\n// \u2500\u2500 DSL Parser \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst SIMILAR_THRESHOLDS = { high: 0.78, medium: 0.68, low: 0.58 }\nconst DEFAULT_THRESHOLD = SIMILAR_THRESHOLDS.medium\nconst DEFAULT_LIMIT = 10\n\n// parseDSL returns { mode, specs, threshold, limit }\n//\n// mode: 'similar' if SIMILAR: is the first meaningful line (ambient auto-run),\n// 'search' otherwise (interactive search form).\n//\n// Ward's convention: ALL-CAPS keyword as first word signals a mode switch.\n// Placing SIMILAR: first makes the item ambient; placing domain specs first\n// (or leaving text empty) keeps it as an interactive search form.\n\nconst parseDSL = text => {\n const specs = []\n let threshold = null\n let limit = null\n let mode = 'search' // default: interactive search form\n let live = false // default: cache results in localStorage\n let force = false // BUILD mode: re-embed even when index is fresh\n let ghostUrl = null // GHOST mode: page-json URL to open as a ghost page\n let label = null // BUTTON: custom button caption (GHOST / BUILD modes)\n\n // Match a keyword at the start of a line (case-insensitive), requiring it to\n // be followed by end-of-string, whitespace, or colon \u2014 not by more word chars.\n // This prevents domain specs like \"similarity.example.com\" matching SIMILAR.\n const isCmd = (upper, kw) => upper === kw || (upper.startsWith(kw) && /^[\\s:]/.test(upper.slice(kw.length)))\n // Extract the value after the keyword, tolerating optional colon and whitespace.\n // Returns '' for bare commands (e.g. \"SIMILAR\" alone), callers use their default.\n const val = (line, kw) => line.slice(kw.length).replace(/^\\s*:?\\s*/, '').trim()\n\n for (const raw of text.split('\\n')) {\n const line = raw.trim()\n if (!line || line.startsWith('#')) continue\n\n const upper = line.toUpperCase()\n if (isCmd(upper, 'LIVE')) { live = true; continue }\n if (isCmd(upper, 'AUTHOR')) {\n if (!specs.length && mode === 'search') mode = 'author'\n continue\n }\n if (isCmd(upper, 'REPORT')) {\n if (mode === 'search') mode = 'report'\n continue\n }\n if (isCmd(upper, 'BUILD')) {\n if (mode === 'search') mode = 'build'\n continue\n }\n if (isCmd(upper, 'FORCE')) { force = true; continue }\n if (isCmd(upper, 'GHOST')) {\n ghostUrl = val(line, 'GHOST')\n if (mode === 'search') mode = 'ghost'\n continue\n }\n if (isCmd(upper, 'BUTTON')) { label = val(line, 'BUTTON'); continue }\n if (isCmd(upper, 'LIST')) {\n if (!specs.length && mode === 'search') mode = 'list'\n continue\n }\n if (isCmd(upper, 'SIMILAR')) {\n const level = val(upper, 'SIMILAR').toLowerCase()\n threshold = SIMILAR_THRESHOLDS[level] || DEFAULT_THRESHOLD // '' \u2192 medium\n if (!specs.length && mode === 'search') mode = 'similar'\n continue\n }\n if (isCmd(upper, 'THRESHOLD')) {\n const tv = val(line, 'THRESHOLD')\n threshold = SIMILAR_THRESHOLDS[tv.toLowerCase()] ?? (parseFloat(tv) || DEFAULT_THRESHOLD)\n continue\n }\n if (isCmd(upper, 'LIMIT')) {\n limit = parseInt(val(line, 'LIMIT')) || DEFAULT_LIMIT // '' \u2192 10\n continue\n }\n // Anything else is a domain spec (glob, explicit domain, or scope keyword)\n specs.push(['PUBLIC', 'LOCAL', 'PRIVATE'].includes(upper) ? upper : line)\n }\n\n return {\n mode,\n specs,\n threshold: threshold ?? DEFAULT_THRESHOLD,\n limit: limit ?? DEFAULT_LIMIT,\n live,\n force,\n ghostUrl,\n label,\n thresholdSet: threshold !== null, // explicit THRESHOLD/SIMILAR in the DSL\n }\n}\n\nconst isGlob = spec => spec.includes('*') || spec.includes('?')\n\n// Scope keywords expand server-side: PUBLIC (Nextcloud mirror farms),\n// LOCAL (primary farm), PRIVATE (public domains with restricted: true)\nconst isScope = spec => spec === 'PUBLIC' || spec === 'LOCAL' || spec === 'PRIVATE'\n\n// \u2500\u2500 Slug \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst slugify = title => title.toLowerCase().replace(/\\s+/g, '-').replace(/[^a-z0-9-]/g, '')\n\n// \u2500\u2500 Domain resolution \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst domainCache = new Map()\n\nconst resolveDomainsForSpec = async (spec, origin) => {\n if (domainCache.has(spec)) return domainCache.get(spec)\n const url = `${origin}/system/indexed-domains.json?pattern=${encodeURIComponent(spec)}`\n const res = await fetch(url)\n if (!res.ok) throw new Error(`indexed-domains failed: ${res.status}`)\n const list = await res.json()\n domainCache.set(spec, list)\n return list\n}\n\nconst resolveDomains = async (specs, origin) => {\n if (!specs.length) specs = [window.location.hostname]\n const seen = new Set()\n const result = []\n for (const spec of specs) {\n if (spec === '*' || isGlob(spec) || isScope(spec)) {\n for (const item of await resolveDomainsForSpec(spec, origin)) {\n if (!seen.has(item.domain)) { seen.add(item.domain); result.push(item) }\n }\n } else if (!seen.has(spec)) {\n seen.add(spec); result.push({ domain: spec, page_count: null })\n }\n }\n return result\n}\n\n// \u2500\u2500 Vector loading \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst vectorCache = new Map()\n\n// Route vector requests through the current wiki's plugin server. This keeps\n// public HTTPS pages same-origin, avoids CORS failures, and lets the server read\n// indices from its configured farm roots.\nconst vectorUrl = domain =>\n `${window.location.origin}/system/semantic-vectors.json?domain=${encodeURIComponent(domain)}`\n\nconst loadVectors = async domain => {\n if (vectorCache.has(domain)) return vectorCache.get(domain)\n const res = await fetch(vectorUrl(domain))\n if (!res.ok) return []\n const data = await res.json()\n vectorCache.set(domain, data)\n return data\n}\n\n// \u2500\u2500 Embedding \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst getEmbedding = async (text, origin) => {\n const res = await fetch(`${origin}/system/embed.json?text=${encodeURIComponent(text)}`)\n if (!res.ok) throw new Error(`embed failed: ${res.status}`)\n return (await res.json()).vector\n}\n\n// Look up an existing page vector from the current domain's cached index.\n// Returns the float[] vector if found, null otherwise.\nconst lookupPageVector = async (slug, domain) => {\n const pages = await loadVectors(domain)\n const entry = pages.find(p => p.slug === slug)\n return entry ? entry.vector : null\n}\n\n// \u2500\u2500 Cosine search \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst cosineScan = (queryVec, domainEntries, { threshold, limit, excludeSlug, excludeDomain }) => {\n const results = []\n for (const { domain, pages } of domainEntries) {\n for (const { slug, title, vector } of pages) {\n if (slug === excludeSlug && domain === excludeDomain) continue\n let dot = 0\n for (let i = 0; i < queryVec.length; i++) dot += queryVec[i] * vector[i]\n if (dot >= threshold) results.push({ domain, slug, title, score: dot })\n }\n }\n results.sort((a, b) => b.score - a.score)\n return results.slice(0, limit)\n}\n\n// \u2500\u2500 Shared domain loading \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst loadDomainEntries = async (specs, origin) => {\n const domains = await resolveDomains(specs, origin)\n const entries = await Promise.all(\n domains.map(async ({ domain }) => ({ domain, pages: await loadVectors(domain) }))\n )\n return entries.filter(e => e.pages.length > 0)\n}\n\n// \u2500\u2500 Result cache (localStorage) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Cache is keyed by item id. Invalidated when item.text changes (DSL edited).\n// LIVE mode bypasses the cache entirely.\n\nconst cacheKey = id => `sim-cache-${id}`\n\nconst readCache = item => {\n try {\n const c = JSON.parse(localStorage.getItem(cacheKey(item.id)) || 'null')\n return c?.text === (item.text || '') ? c : null\n } catch { return null }\n}\n\nconst writeCache = (item, data) => {\n try {\n localStorage.setItem(cacheKey(item.id), JSON.stringify({\n text: item.text || '',\n ts: Date.now(),\n ...data,\n }))\n } catch { /* storage unavailable or full */ }\n}\n\nconst cacheAge = ts => {\n const s = Math.floor((Date.now() - ts) / 1000)\n if (s < 60) return `${s}s ago`\n if (s < 3600) return `${Math.floor(s / 60)}m ago`\n if (s < 86400) return `${Math.floor(s / 3600)}h ago`\n return `${Math.floor(s / 86400)}d ago`\n}\n\n// \u2500\u2500 Styles \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst STYLES = `\n .sim-form { display:flex; gap:6px; margin-bottom:8px; }\n .sim-input { flex:1; padding:6px 8px; font-size:14px; border:1px solid #ccc; border-radius:3px; }\n .sim-btn { padding:6px 14px; background:#c4561d; color:white; border:none;\n border-radius:3px; cursor:pointer; font-size:14px; }\n .sim-btn:disabled { opacity:0.5; cursor:not-allowed; }\n .sim-status { font-size:12px; color:#888; margin-bottom:6px; min-height:16px; }\n .sim-results { margin-top:4px; }\n .sim-result { display:flex; align-items:center; gap:8px; padding:3px 0;\n border-bottom:1px solid #f0f0f0; }\n .sim-flag { width:16px; height:16px; vertical-align:middle; margin-right:4px; }\n .sim-link { font-size:14px; color:#406; flex:1; }\n .sim-domain { font-size:11px; color:#999; }\n .similar-results h3, .sim-list h3 { margin:4px 0 6px; font-size:14px; color:#555; }\n .similar-results ul { margin:0; padding-left:18px; }\n .similar-results li { font-size:14px; padding:2px 0; }\n .similar-results .sim-domain { margin-left:6px; }\n .sim-count { font-size:12px; color:#888; margin:4px 0 0; }\n .sim-list table { border-collapse:collapse; width:100%; font-size:13px; }\n .sim-list th { text-align:left; font-size:11px; color:#888; padding:2px 8px 4px 0;\n border-bottom:1px solid #ddd; }\n .sim-list td { padding:3px 8px 3px 0; border-bottom:1px solid #f0f0f0; vertical-align:middle; }\n .sim-list td:last-child { text-align:right; color:#999; font-size:11px; }\n .sim-list .sim-flag { margin-right:6px; }\n`\n\nconst siteFlag = (domain, score) =>\n `<img class=\"sim-flag remote\" src=\"${window.wiki.site(domain).flag()}\"\n title=\"${domain} \u2014 score ${score.toFixed(3)}\"\n data-site=\"${domain}\">`\n\n// \u2500\u2500 similarity item \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst simLink = (domain, slug, title, score) =>\n `<a class=\"sim-link\" data-title=\"${title}\" data-slug=\"${slug}\" data-site=\"${domain}\" href=\"#\">` +\n `${siteFlag(domain, score)} ${title}</a>`\n\nexport const emit = (div, item) => {\n const { mode, specs, threshold, limit, force, ghostUrl, label } = parseDSL(item?.text || '')\n if (mode === 'ghost') {\n div.html(`\n <style>${STYLES}</style>\n <div class=\"similarity\" data-id=\"${item.id}\">\n <div class=\"sim-form\">\n <button class=\"sim-btn\">${label || 'Open'}</button>\n </div>\n <div class=\"sim-status\"></div>\n </div>`)\n } else if (mode === 'build') {\n div.html(`\n <style>${STYLES}</style>\n <div class=\"similarity\" data-id=\"${item.id}\">\n <div class=\"sim-form\">\n <button class=\"sim-btn\">${label || `Index ${specs.length ? specs.join(', ') : '*'}${force ? ' (force)' : ''}`}</button>\n </div>\n <div class=\"sim-status\"></div>\n </div>`)\n } else if (mode === 'list') {\n const label = specs.length ? specs.join(', ') : '*'\n div.html(`\n <style>${STYLES}</style>\n <div class=\"similarity\" data-id=\"${item.id}\">\n <div class=\"sim-status\">Loading indexed domains (${label})\u2026</div>\n <div class=\"sim-list\"></div>\n </div>`)\n } else if (mode === 'similar') {\n const label = specs.length ? specs.join(', ') : 'current domain'\n div.html(`\n <style>${STYLES}</style>\n <div class=\"similarity\" data-id=\"${item.id}\">\n <div class=\"sim-status\">Finding similar pages across ${label}\u2026</div>\n <div class=\"sim-results\"></div>\n </div>`)\n } else if (mode === 'author' || mode === 'report') {\n const label = specs.length ? specs.join(', ') : '(current domain)'\n const btnLabel = mode === 'report' ? 'Report' : 'Author'\n div.html(`\n <style>${STYLES}</style>\n <div class=\"similarity\" data-id=\"${item.id}\">\n <div class=\"sim-form\">\n <input class=\"sim-input\" type=\"text\" placeholder=\"Search wiki pages\u2026\" />\n <button class=\"sim-btn\">${btnLabel}</button>\n </div>\n <div class=\"sim-status\">Domains: ${label}</div>\n <div class=\"sim-results\"></div>\n </div>`)\n } else {\n const label = specs.length ? specs.join(', ') : '(current domain)'\n div.html(`\n <style>${STYLES}</style>\n <div class=\"similarity\" data-id=\"${item.id}\">\n <div class=\"sim-form\">\n <input class=\"sim-input\" type=\"text\" placeholder=\"Search wiki pages\u2026\" />\n <button class=\"sim-btn\">Search</button>\n </div>\n <div class=\"sim-status\">Domains: ${label}</div>\n <div class=\"sim-results\"></div>\n </div>`)\n }\n}\n\nexport const bind = (div, item) => {\n const { mode, specs, threshold, limit, live, force, ghostUrl, thresholdSet } =\n parseDSL(item?.text || '')\n const origin = window.location.origin\n const status = div.find('.sim-status')[0]\n const cache = live ? null : readCache(item)\n\n // Standardised pre-search status: what will run, over how much, with what config.\n // e.g. \"Report ready \u2014 18,583 pages across 267 domains \u00B7 threshold 0.68 \u00B7 limit 20 \u00B7 LIVE\"\n const configSummary = (verb, pages, nDomains) => {\n const parts = [`${verb} \u2014 ${pages.toLocaleString()} pages across ${nDomains} domains`]\n if (thresholdSet) parts.push(`threshold ${threshold}`)\n parts.push(`limit ${limit}`)\n if (live) parts.push('LIVE')\n return parts.join(' \u00B7 ')\n }\n\n div.on('dblclick', e => {\n if ($(e.target).closest('.sim-input').length) return\n window.wiki.textEditor(div, item)\n })\n div.on('click', '.sim-link', function (e) {\n e.preventDefault()\n const $a = $(this)\n // shift-click appends at the end of the lineup instead of truncating after this page\n const $page = e.shiftKey ? null : div.parents('.page')\n window.wiki.doInternalLink($a.data('title'), $page, $a.data('site'))\n })\n\n const scopeLabel = !specs.length || (specs.length === 1 && specs[0] === '*')\n ? 'on farm'\n : specs.length === 1 ? `on ${specs[0]}` : 'in domains'\n\n const cacheNote = ts => ts ? ` \u00B7 cached ${cacheAge(ts)}` : ''\n\n if (mode === 'list') {\n const listDiv = div.find('.sim-list')[0]\n const patterns = specs.length ? specs.join(',') : '*'\n\n const renderList = (domains, ts) => {\n const totalPages = domains.reduce((n, d) => n + (d.page_count || 0), 0)\n status.style.display = 'none'\n listDiv.innerHTML = `<h3>Indexed Farm Domains</h3>\n <table>\n <tr><th>Domain</th><th>Pages</th></tr>\n ${domains.map(({ domain, page_count }) => `\n <tr>\n <td><img class=\"sim-flag remote\" src=\"${window.wiki.site(domain).flag()}\"\n title=\"${domain}\" data-site=\"${domain}\"> ${domain}</td>\n <td>${page_count != null ? page_count.toLocaleString() : '\u2014'}</td>\n </tr>`).join('')}\n </table>\n <p class=\"sim-count\">${domains.length} domains \u2014 ${totalPages.toLocaleString()} pages${cacheNote(ts)}</p>`\n }\n\n if (cache?.domains) {\n renderList(cache.domains, cache.ts)\n } else {\n ;(async () => {\n try {\n const url = `${origin}/system/indexed-domains.json?pattern=${encodeURIComponent(patterns)}&limit=${limit}`\n const res = await fetch(url)\n if (!res.ok) throw new Error(`indexed-domains failed: ${res.status}`)\n const domains = await res.json()\n if (!domains.length) { status.textContent = 'No indexed domains found'; return }\n renderList(domains, null)\n writeCache(item, { domains })\n } catch (e) {\n status.textContent = `Error: ${e.message}`\n }\n })()\n }\n\n } else if (mode === 'similar') {\n const results = div.find('.sim-results')[0]\n\n const renderScored = (scored, ts) => {\n if (!scored.length) {\n status.textContent = `No similar pages found above threshold ${threshold}`\n return\n }\n results.innerHTML = `<h3>Similar Pages</h3><ul>${\n scored.map(({ domain, slug, title, score }) =>\n `<li>${simLink(domain, slug, title, score)}</li>`).join('')\n }</ul><p class=\"sim-count\">${scored.length} found ${scopeLabel}${cacheNote(ts)}</p>`\n status.style.display = 'none'\n }\n\n if (cache?.scored) {\n renderScored(cache.scored, cache.ts)\n } else {\n ;(async () => {\n try {\n const $page = div.parents('.page')\n const pageTitle = $page.find('.title').text().trim() || document.title\n const currentSlug = slugify(pageTitle)\n const currentDomain = window.location.hostname\n\n const domainEntries = await loadDomainEntries(specs, origin)\n const total = domainEntries.reduce((n, e) => n + e.pages.length, 0)\n status.textContent = `Searching ${total.toLocaleString()} pages\u2026`\n\n let qVec = await lookupPageVector(currentSlug, currentDomain)\n if (!qVec) {\n status.textContent = 'Embedding page (not yet indexed)\u2026'\n const pageText = $page.find('.item').map((_, el) => $(el).text().trim()).get().filter(Boolean).join('\\n')\n qVec = await getEmbedding(pageText || pageTitle, origin)\n }\n\n const scored = cosineScan(qVec, domainEntries, {\n threshold, limit, excludeSlug: currentSlug, excludeDomain: currentDomain,\n })\n renderScored(scored, null)\n if (scored.length) writeCache(item, { scored })\n } catch (e) {\n status.textContent = `Error: ${e.message}`\n }\n })()\n }\n\n } else if (mode === 'ghost') {\n // Ghost mode \u2014 fetch page-json from any URL and open it as a ghost page\n const btn = div.find('.sim-btn')[0]\n\n const doGhost = async () => {\n if (!ghostUrl) { status.textContent = 'No URL \u2014 GHOST needs a page-json URL'; return }\n btn.disabled = true\n status.textContent = 'Fetching\u2026'\n try {\n const res = await fetch(ghostUrl)\n if (!res.ok) throw new Error(`fetch failed: ${res.status}`)\n const page = await res.json()\n window.wiki.showResult(window.wiki.newPage(page), { $page: div.parents('.page') })\n status.textContent = ''\n } catch (e) {\n status.textContent = `Error: ${e.message}`\n } finally {\n btn.disabled = false\n }\n }\n\n btn.addEventListener('click', doGhost)\n\n } else if (mode === 'build') {\n // Build mode \u2014 trigger a semantic index build; result opens as a ghost page\n const btn = div.find('.sim-btn')[0]\n\n const doBuild = async () => {\n btn.disabled = true\n status.textContent = 'Building index\u2026 (may take a while for large scopes)'\n try {\n const domains = encodeURIComponent((specs.length ? specs : ['*']).join(','))\n const res = await fetch(\n `http://api.localhost/build-index.json?domains=${domains}&force=${force ? 1 : 0}`)\n if (!res.ok) throw new Error(`build-index failed: ${res.status}`)\n const page = await res.json()\n window.wiki.showResult(window.wiki.newPage(page), { $page: div.parents('.page') })\n status.textContent = ''\n } catch (e) {\n status.textContent = `Error: ${e.message}`\n } finally {\n btn.disabled = false\n }\n }\n\n btn.addEventListener('click', doBuild)\n\n } else if (mode === 'report') {\n // Report mode \u2014 server-side ranked/bundled search, opens result as ghost page\n const input = div.find('.sim-input')[0]\n const btn = div.find('.sim-btn')[0]\n let readyLine = `Domains: ${specs.length ? specs.join(', ') : '*'}`\n\n // Preload the lightweight domain listing (counts only, no vectors) so the\n // status line shows scope and config before any search is issued.\n ;(async () => {\n try {\n const domains = await resolveDomains(specs.length ? specs : ['*'], origin)\n const pages = domains.reduce((n, d) => n + (d.page_count || 0), 0)\n readyLine = configSummary('Report ready', pages, domains.length)\n status.textContent = readyLine\n } catch (e) {\n status.textContent = `Domain listing unavailable: ${e.message}`\n }\n })()\n\n const doReport = async () => {\n const query = input.value.trim()\n if (!query) return\n btn.disabled = true\n status.textContent = 'Generating report\u2026'\n try {\n const body = { query, domains: specs.length ? specs : ['*'], limit, live }\n if (thresholdSet) body.threshold = threshold\n const res = await fetch('http://api.localhost/search-report', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n })\n if (!res.ok) throw new Error(`search-report failed: ${res.status}`)\n const page = await res.json()\n const pageObj = window.wiki.newPage(page)\n window.wiki.showResult(pageObj, { $page: div.parents('.page') })\n status.textContent = readyLine\n } catch (e) {\n status.textContent = `Error: ${e.message}`\n } finally {\n btn.disabled = false\n }\n }\n\n btn.addEventListener('click', doReport)\n input.addEventListener('keydown', e => { if (e.key === 'Enter') doReport() })\n\n } else if (mode === 'author') {\n // Author mode \u2014 same search form but creates a ghost page from results\n const input = div.find('.sim-input')[0]\n const btn = div.find('.sim-btn')[0]\n const results = div.find('.sim-results')[0]\n let domainEntries = null\n\n ;(async () => {\n try {\n if (!cache) status.textContent = 'Resolving domains\u2026'\n domainEntries = await loadDomainEntries(specs, origin)\n const total = domainEntries.reduce((n, e) => n + e.pages.length, 0)\n status.textContent = configSummary('Author ready', total, domainEntries.length)\n } catch (e) {\n status.textContent = `Load error: ${e.message}`\n }\n })()\n\n const doAuthor = async () => {\n const query = input.value.trim()\n if (!query || !domainEntries) return\n btn.disabled = true\n status.textContent = 'Embedding query\u2026'\n results.innerHTML = ''\n try {\n const qVec = await getEmbedding(query, origin)\n const scored = cosineScan(qVec, domainEntries,\n { threshold, limit, excludeSlug: null, excludeDomain: null })\n\n // Unique titles for wiki-link list\n const seenTitles = new Set()\n const uniqueTitles = []\n for (const { title } of scored) {\n if (!seenTitles.has(title)) { seenTitles.add(title); uniqueTitles.push(title) }\n }\n\n const hexId = () => Math.floor(Math.random() * 0xffffffffffffffff).toString(16).padStart(16, '0')\n const primaryLines = uniqueTitles.map(t => `- [[${t}]]`).join('\\n')\n\n const story = [\n { type: 'markdown', id: hexId(), text: `# Similar Pages\\n\\n${primaryLines}` },\n { type: 'markdown', id: hexId(), text: '# Reference Links' },\n ...scored.map(({ domain, slug, title, score }) => ({\n type: 'reference', id: hexId(), site: domain, slug, title,\n text: `score ${score.toFixed(3)}`,\n })),\n ]\n\n const pageObj = window.wiki.newPage({ title: `${query} Results`, story, journal: [] })\n window.wiki.showResult(pageObj, { $page: div.parents('.page') })\n\n status.textContent = `${scored.length} pages found`\n writeCache(item, { scored, query })\n } catch (e) {\n status.textContent = `Error: ${e.message}`\n } finally {\n btn.disabled = false\n }\n }\n\n btn.addEventListener('click', doAuthor)\n input.addEventListener('keydown', e => { if (e.key === 'Enter') doAuthor() })\n\n } else {\n // Search form mode\n const input = div.find('.sim-input')[0]\n const btn = div.find('.sim-btn')[0]\n const results = div.find('.sim-results')[0]\n let domainEntries = null\n\n // Show cached results immediately while domains preload in background\n if (cache?.scored) {\n input.value = cache.query || ''\n results.innerHTML = cache.scored.map(({ domain, slug, title, score }) =>\n `<div class=\"sim-result\">${simLink(domain, slug, title, score)}</div>`).join('') +\n `<p class=\"sim-count\">Top ${cache.scored.length} for \"${cache.query || ''}\"${cacheNote(cache.ts)}</p>`\n status.textContent = ''\n }\n\n ;(async () => {\n try {\n if (!cache) status.textContent = 'Resolving domains\u2026'\n domainEntries = await loadDomainEntries(specs, origin)\n const total = domainEntries.reduce((n, e) => n + e.pages.length, 0)\n status.textContent = configSummary('Search ready', total, domainEntries.length)\n } catch (e) {\n status.textContent = `Load error: ${e.message}`\n }\n })()\n\n const doSearch = async () => {\n const query = input.value.trim()\n if (!query || !domainEntries) return\n btn.disabled = true\n status.textContent = 'Embedding query\u2026'\n results.innerHTML = ''\n try {\n const qVec = await getEmbedding(query, origin)\n const scored = cosineScan(qVec, domainEntries,\n { threshold, limit, excludeSlug: null, excludeDomain: null })\n results.innerHTML = scored.map(({ domain, slug, title, score }) =>\n `<div class=\"sim-result\">${simLink(domain, slug, title, score)}</div>`).join('') +\n `<p class=\"sim-count\">Top ${scored.length} for \"${query}\"</p>`\n status.textContent = ''\n writeCache(item, { scored, query })\n } catch (e) {\n status.textContent = `Error: ${e.message}`\n } finally {\n btn.disabled = false\n }\n }\n\n btn.addEventListener('click', doSearch)\n input.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch() })\n }\n}\n\n// \u2500\u2500 Register item type with the wiki \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nif (typeof window !== 'undefined') {\n window.plugins = window.plugins || {}\n window.plugins.similarity = { emit, bind }\n}\n"],
5
- "mappings": "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",
6
- "names": ["SIMILAR_THRESHOLDS", "DEFAULT_THRESHOLD", "DEFAULT_LIMIT", "parseDSL", "text", "specs", "threshold", "limit", "mode", "live", "force", "ghostUrl", "label", "isCmd", "upper", "kw", "val", "line", "raw", "level", "tv", "isGlob", "spec", "isScope", "slugify", "title", "domainCache", "resolveDomainsForSpec", "origin", "url", "res", "list", "resolveDomains", "seen", "result", "item", "vectorCache", "vectorUrl", "domain", "loadVectors", "data", "getEmbedding", "lookupPageVector", "slug", "entry", "p", "cosineScan", "queryVec", "domainEntries", "excludeSlug", "excludeDomain", "results", "pages", "vector", "dot", "i", "a", "loadDomainEntries", "domains", "e", "cacheKey", "id", "readCache", "c", "writeCache", "cacheAge", "ts", "s", "STYLES", "siteFlag", "score", "simLink", "emit", "div", "btnLabel", "bind", "thresholdSet", "status", "cache", "configSummary", "verb", "nDomains", "parts", "$a", "$page", "scopeLabel", "cacheNote", "listDiv", "patterns", "renderList", "totalPages", "n", "d", "page_count", "renderScored", "scored", "pageTitle", "currentSlug", "currentDomain", "total", "qVec", "pageText", "_", "el", "btn", "doGhost", "page", "doBuild", "input", "readyLine", "doReport", "query", "body", "pageObj", "doAuthor", "seenTitles", "uniqueTitles", "hexId", "primaryLines", "t", "story", "doSearch"]
4
+ "sourcesContent": ["// wiki-plugin-similarity\n//\n// One item type: similarity\n//\n// DSL in item.text (newline-separated lines):\n// * all indexed domains on this server\n// david.* glob pattern\n// *.fish glob pattern\n// david.hitchhikers.earth explicit domain name\n// # comment ignored\n// LIST list indexed domains (mode directive)\n// SIMILAR: high threshold preset AND ambient mode trigger\n// THRESHOLD: 0.72 exact cosine threshold (overrides SIMILAR:)\n// LIMIT: 8 max results shown (default 10)\n//\n// Mode is determined by the FIRST meaningful line (Ward's ALL-CAPS convention):\n// LIST \u2192 show a table of all indexed domains and their page counts;\n// optional glob patterns on subsequent lines filter the list\n// SIMILAR: \u2192 ambient mode \u2014 automatically find pages similar to this page\n// REPORT \u2192 server-side ranked/bundled semantic report (ghost page)\n// KEYWORD \u2192 galactic MiniSearch over live site-index.json files (ghost page)\n// (other) \u2192 search form mode \u2014 user types a query, results appear\n//\n// Server endpoints required (all same-origin, served by this plugin's server\n// component \u2014 works on any host, including the public farm):\n// GET /system/indexed-domains.json?pattern=glob1,glob2\n// GET /system/semantic-vectors.json?domain=\n// GET /system/embed.json?text=query\n// POST /system/search-report.json\n// GET /system/farm-search.json?q=&pattern=&limit=\n// GET /system/build-index.json?domains=&force=\n\n// \u2500\u2500 DSL Parser \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst SIMILAR_THRESHOLDS = { high: 0.78, medium: 0.68, low: 0.58 }\nconst DEFAULT_THRESHOLD = SIMILAR_THRESHOLDS.medium\nconst DEFAULT_LIMIT = 10\n\n// parseDSL returns { mode, specs, threshold, limit }\n//\n// mode: 'similar' if SIMILAR: is the first meaningful line (ambient auto-run),\n// 'search' otherwise (interactive search form).\n//\n// Ward's convention: ALL-CAPS keyword as first word signals a mode switch.\n// Placing SIMILAR: first makes the item ambient; placing domain specs first\n// (or leaving text empty) keeps it as an interactive search form.\n\nconst parseDSL = text => {\n const specs = []\n let threshold = null\n let limit = null\n let mode = 'search' // default: interactive search form\n let live = false // default: cache results in localStorage\n let force = false // BUILD mode: re-embed even when index is fresh\n let ghostUrl = null // GHOST mode: page-json URL to open as a ghost page\n let label = null // BUTTON: custom button caption (GHOST / BUILD modes)\n\n // Match a keyword at the start of a line (case-insensitive), requiring it to\n // be followed by end-of-string, whitespace, or colon \u2014 not by more word chars.\n // This prevents domain specs like \"similarity.example.com\" matching SIMILAR.\n const isCmd = (upper, kw) => upper === kw || (upper.startsWith(kw) && /^[\\s:]/.test(upper.slice(kw.length)))\n // Extract the value after the keyword, tolerating optional colon and whitespace.\n // Returns '' for bare commands (e.g. \"SIMILAR\" alone), callers use their default.\n const val = (line, kw) => line.slice(kw.length).replace(/^\\s*:?\\s*/, '').trim()\n\n for (const raw of text.split('\\n')) {\n const line = raw.trim()\n if (!line || line.startsWith('#')) continue\n\n const upper = line.toUpperCase()\n if (isCmd(upper, 'LIVE')) { live = true; continue }\n if (isCmd(upper, 'AUTHOR')) {\n if (!specs.length && mode === 'search') mode = 'author'\n continue\n }\n if (isCmd(upper, 'REPORT')) {\n if (mode === 'search') mode = 'report'\n continue\n }\n if (isCmd(upper, 'KEYWORD')) {\n if (mode === 'search') mode = 'keyword'\n continue\n }\n if (isCmd(upper, 'BUILD')) {\n if (mode === 'search') mode = 'build'\n continue\n }\n if (isCmd(upper, 'FORCE')) { force = true; continue }\n if (isCmd(upper, 'GHOST')) {\n ghostUrl = val(line, 'GHOST')\n if (mode === 'search') mode = 'ghost'\n continue\n }\n if (isCmd(upper, 'BUTTON')) { label = val(line, 'BUTTON'); continue }\n if (isCmd(upper, 'LIST')) {\n if (!specs.length && mode === 'search') mode = 'list'\n continue\n }\n if (isCmd(upper, 'SIMILAR')) {\n const level = val(upper, 'SIMILAR').toLowerCase()\n threshold = SIMILAR_THRESHOLDS[level] || DEFAULT_THRESHOLD // '' \u2192 medium\n if (!specs.length && mode === 'search') mode = 'similar'\n continue\n }\n if (isCmd(upper, 'THRESHOLD')) {\n const tv = val(line, 'THRESHOLD')\n threshold = SIMILAR_THRESHOLDS[tv.toLowerCase()] ?? (parseFloat(tv) || DEFAULT_THRESHOLD)\n continue\n }\n if (isCmd(upper, 'LIMIT')) {\n limit = parseInt(val(line, 'LIMIT')) || DEFAULT_LIMIT // '' \u2192 10\n continue\n }\n // Anything else is a domain spec (glob, explicit domain, or scope keyword)\n specs.push(['PUBLIC', 'LOCAL', 'PRIVATE'].includes(upper) ? upper : line)\n }\n\n return {\n mode,\n specs,\n threshold: threshold ?? DEFAULT_THRESHOLD,\n limit: limit ?? DEFAULT_LIMIT,\n live,\n force,\n ghostUrl,\n label,\n thresholdSet: threshold !== null, // explicit THRESHOLD/SIMILAR in the DSL\n }\n}\n\nconst isGlob = spec => spec.includes('*') || spec.includes('?')\n\n// Scope keywords expand server-side: PUBLIC (Nextcloud mirror farms),\n// LOCAL (primary farm), PRIVATE (public domains with restricted: true)\nconst isScope = spec => spec === 'PUBLIC' || spec === 'LOCAL' || spec === 'PRIVATE'\n\n// \u2500\u2500 Slug \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst slugify = title => title.toLowerCase().replace(/\\s+/g, '-').replace(/[^a-z0-9-]/g, '')\n\n// \u2500\u2500 Domain resolution \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst domainCache = new Map()\n\nconst resolveDomainsForSpec = async (spec, origin) => {\n if (domainCache.has(spec)) return domainCache.get(spec)\n const url = `${origin}/system/indexed-domains.json?pattern=${encodeURIComponent(spec)}`\n const res = await fetch(url)\n if (!res.ok) throw new Error(`indexed-domains failed: ${res.status}`)\n const list = await res.json()\n domainCache.set(spec, list)\n return list\n}\n\nconst resolveDomains = async (specs, origin) => {\n if (!specs.length) specs = [window.location.hostname]\n const seen = new Set()\n const result = []\n for (const spec of specs) {\n if (spec === '*' || isGlob(spec) || isScope(spec)) {\n for (const item of await resolveDomainsForSpec(spec, origin)) {\n if (!seen.has(item.domain)) { seen.add(item.domain); result.push(item) }\n }\n } else if (!seen.has(spec)) {\n seen.add(spec); result.push({ domain: spec, page_count: null })\n }\n }\n return result\n}\n\n// \u2500\u2500 Vector loading \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst vectorCache = new Map()\n\n// Route vector requests through the current wiki's plugin server. This keeps\n// public HTTPS pages same-origin, avoids CORS failures, and lets the server read\n// indices from its configured farm roots.\nconst vectorUrl = domain =>\n `${window.location.origin}/system/semantic-vectors.json?domain=${encodeURIComponent(domain)}`\n\nconst loadVectors = async domain => {\n if (vectorCache.has(domain)) return vectorCache.get(domain)\n const res = await fetch(vectorUrl(domain))\n if (!res.ok) return []\n const data = await res.json()\n vectorCache.set(domain, data)\n return data\n}\n\n// \u2500\u2500 Embedding \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst getEmbedding = async (text, origin) => {\n const res = await fetch(`${origin}/system/embed.json?text=${encodeURIComponent(text)}`)\n if (!res.ok) throw new Error(`embed failed: ${res.status}`)\n return (await res.json()).vector\n}\n\n// Look up an existing page vector from the current domain's cached index.\n// Returns the float[] vector if found, null otherwise.\nconst lookupPageVector = async (slug, domain) => {\n const pages = await loadVectors(domain)\n const entry = pages.find(p => p.slug === slug)\n return entry ? entry.vector : null\n}\n\n// \u2500\u2500 Cosine search \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst cosineScan = (queryVec, domainEntries, { threshold, limit, excludeSlug, excludeDomain }) => {\n const results = []\n for (const { domain, pages } of domainEntries) {\n for (const { slug, title, vector } of pages) {\n if (slug === excludeSlug && domain === excludeDomain) continue\n let dot = 0\n for (let i = 0; i < queryVec.length; i++) dot += queryVec[i] * vector[i]\n if (dot >= threshold) results.push({ domain, slug, title, score: dot })\n }\n }\n results.sort((a, b) => b.score - a.score)\n return results.slice(0, limit)\n}\n\n// \u2500\u2500 Shared domain loading \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst loadDomainEntries = async (specs, origin) => {\n const domains = await resolveDomains(specs, origin)\n const entries = await Promise.all(\n domains.map(async ({ domain }) => ({ domain, pages: await loadVectors(domain) }))\n )\n return entries.filter(e => e.pages.length > 0)\n}\n\n// \u2500\u2500 Result cache (localStorage) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Cache is keyed by item id. Invalidated when item.text changes (DSL edited).\n// LIVE mode bypasses the cache entirely.\n\nconst cacheKey = id => `sim-cache-${id}`\n\nconst readCache = item => {\n try {\n const c = JSON.parse(localStorage.getItem(cacheKey(item.id)) || 'null')\n return c?.text === (item.text || '') ? c : null\n } catch { return null }\n}\n\nconst writeCache = (item, data) => {\n try {\n localStorage.setItem(cacheKey(item.id), JSON.stringify({\n text: item.text || '',\n ts: Date.now(),\n ...data,\n }))\n } catch { /* storage unavailable or full */ }\n}\n\nconst cacheAge = ts => {\n const s = Math.floor((Date.now() - ts) / 1000)\n if (s < 60) return `${s}s ago`\n if (s < 3600) return `${Math.floor(s / 60)}m ago`\n if (s < 86400) return `${Math.floor(s / 3600)}h ago`\n return `${Math.floor(s / 86400)}d ago`\n}\n\n// \u2500\u2500 Styles \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst STYLES = `\n .sim-form { display:flex; gap:6px; margin-bottom:8px; }\n .sim-input { flex:1; padding:6px 8px; font-size:14px; border:1px solid #ccc; border-radius:3px; }\n .sim-btn { padding:6px 14px; background:#c4561d; color:white; border:none;\n border-radius:3px; cursor:pointer; font-size:14px; }\n .sim-btn:disabled { opacity:0.5; cursor:not-allowed; }\n .sim-status { font-size:12px; color:#888; margin-bottom:6px; min-height:16px; }\n .sim-results { margin-top:4px; }\n .sim-result { display:flex; align-items:center; gap:8px; padding:3px 0;\n border-bottom:1px solid #f0f0f0; }\n .sim-flag { width:16px; height:16px; vertical-align:middle; margin-right:4px; }\n .sim-link { font-size:14px; color:#406; flex:1; }\n .sim-domain { font-size:11px; color:#999; }\n .similar-results h3, .sim-list h3 { margin:4px 0 6px; font-size:14px; color:#555; }\n .similar-results ul { margin:0; padding-left:18px; }\n .similar-results li { font-size:14px; padding:2px 0; }\n .similar-results .sim-domain { margin-left:6px; }\n .sim-count { font-size:12px; color:#888; margin:4px 0 0; }\n .sim-list table { border-collapse:collapse; width:100%; font-size:13px; }\n .sim-list th { text-align:left; font-size:11px; color:#888; padding:2px 8px 4px 0;\n border-bottom:1px solid #ddd; }\n .sim-list td { padding:3px 8px 3px 0; border-bottom:1px solid #f0f0f0; vertical-align:middle; }\n .sim-list td:last-child { text-align:right; color:#999; font-size:11px; }\n .sim-list .sim-flag { margin-right:6px; }\n`\n\nconst siteFlag = (domain, score) =>\n `<img class=\"sim-flag remote\" src=\"${window.wiki.site(domain).flag()}\"\n title=\"${domain} \u2014 score ${score.toFixed(3)}\"\n data-site=\"${domain}\">`\n\n// \u2500\u2500 similarity item \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst simLink = (domain, slug, title, score) =>\n `<a class=\"sim-link\" data-title=\"${title}\" data-slug=\"${slug}\" data-site=\"${domain}\" href=\"#\">` +\n `${siteFlag(domain, score)} ${title}</a>`\n\nexport const emit = (div, item) => {\n const { mode, specs, threshold, limit, force, ghostUrl, label } = parseDSL(item?.text || '')\n if (mode === 'ghost') {\n div.html(`\n <style>${STYLES}</style>\n <div class=\"similarity\" data-id=\"${item.id}\">\n <div class=\"sim-form\">\n <button class=\"sim-btn\">${label || 'Open'}</button>\n </div>\n <div class=\"sim-status\"></div>\n </div>`)\n } else if (mode === 'build') {\n div.html(`\n <style>${STYLES}</style>\n <div class=\"similarity\" data-id=\"${item.id}\">\n <div class=\"sim-form\">\n <button class=\"sim-btn\">${label || `Index ${specs.length ? specs.join(', ') : '*'}${force ? ' (force)' : ''}`}</button>\n </div>\n <div class=\"sim-status\"></div>\n </div>`)\n } else if (mode === 'list') {\n const label = specs.length ? specs.join(', ') : '*'\n div.html(`\n <style>${STYLES}</style>\n <div class=\"similarity\" data-id=\"${item.id}\">\n <div class=\"sim-status\">Loading indexed domains (${label})\u2026</div>\n <div class=\"sim-list\"></div>\n </div>`)\n } else if (mode === 'similar') {\n const label = specs.length ? specs.join(', ') : 'current domain'\n div.html(`\n <style>${STYLES}</style>\n <div class=\"similarity\" data-id=\"${item.id}\">\n <div class=\"sim-status\">Finding similar pages across ${label}\u2026</div>\n <div class=\"sim-results\"></div>\n </div>`)\n } else if (mode === 'author' || mode === 'report' || mode === 'keyword') {\n const label = specs.length ? specs.join(', ') : '(current domain)'\n const btnLabel = mode === 'report' ? 'Report' : mode === 'keyword' ? 'Keyword' : 'Author'\n div.html(`\n <style>${STYLES}</style>\n <div class=\"similarity\" data-id=\"${item.id}\">\n <div class=\"sim-form\">\n <input class=\"sim-input\" type=\"text\" placeholder=\"Search wiki pages\u2026\" />\n <button class=\"sim-btn\">${btnLabel}</button>\n </div>\n <div class=\"sim-status\">Domains: ${label}</div>\n <div class=\"sim-results\"></div>\n </div>`)\n } else {\n const label = specs.length ? specs.join(', ') : '(current domain)'\n div.html(`\n <style>${STYLES}</style>\n <div class=\"similarity\" data-id=\"${item.id}\">\n <div class=\"sim-form\">\n <input class=\"sim-input\" type=\"text\" placeholder=\"Search wiki pages\u2026\" />\n <button class=\"sim-btn\">Search</button>\n </div>\n <div class=\"sim-status\">Domains: ${label}</div>\n <div class=\"sim-results\"></div>\n </div>`)\n }\n}\n\nexport const bind = (div, item) => {\n const { mode, specs, threshold, limit, live, force, ghostUrl, thresholdSet } =\n parseDSL(item?.text || '')\n const origin = window.location.origin\n const status = div.find('.sim-status')[0]\n const cache = live ? null : readCache(item)\n\n // Standardised pre-search status: what will run, over how much, with what config.\n // e.g. \"Report ready \u2014 18,583 pages across 267 domains \u00B7 threshold 0.68 \u00B7 limit 20 \u00B7 LIVE\"\n const configSummary = (verb, pages, nDomains) => {\n const parts = [`${verb} \u2014 ${pages.toLocaleString()} pages across ${nDomains} domains`]\n if (thresholdSet) parts.push(`threshold ${threshold}`)\n parts.push(`limit ${limit}`)\n if (live) parts.push('LIVE')\n return parts.join(' \u00B7 ')\n }\n\n div.on('dblclick', e => {\n if ($(e.target).closest('.sim-input').length) return\n window.wiki.textEditor(div, item)\n })\n div.on('click', '.sim-link', function (e) {\n e.preventDefault()\n const $a = $(this)\n // shift-click appends at the end of the lineup instead of truncating after this page\n const $page = e.shiftKey ? null : div.parents('.page')\n window.wiki.doInternalLink($a.data('title'), $page, $a.data('site'))\n })\n\n const scopeLabel = !specs.length || (specs.length === 1 && specs[0] === '*')\n ? 'on farm'\n : specs.length === 1 ? `on ${specs[0]}` : 'in domains'\n\n const cacheNote = ts => ts ? ` \u00B7 cached ${cacheAge(ts)}` : ''\n\n if (mode === 'list') {\n const listDiv = div.find('.sim-list')[0]\n const patterns = specs.length ? specs.join(',') : '*'\n\n const renderList = (domains, ts) => {\n const totalPages = domains.reduce((n, d) => n + (d.page_count || 0), 0)\n status.style.display = 'none'\n listDiv.innerHTML = `<h3>Indexed Farm Domains</h3>\n <table>\n <tr><th>Domain</th><th>Pages</th></tr>\n ${domains.map(({ domain, page_count }) => `\n <tr>\n <td><img class=\"sim-flag remote\" src=\"${window.wiki.site(domain).flag()}\"\n title=\"${domain}\" data-site=\"${domain}\"> ${domain}</td>\n <td>${page_count != null ? page_count.toLocaleString() : '\u2014'}</td>\n </tr>`).join('')}\n </table>\n <p class=\"sim-count\">${domains.length} domains \u2014 ${totalPages.toLocaleString()} pages${cacheNote(ts)}</p>`\n }\n\n if (cache?.domains) {\n renderList(cache.domains, cache.ts)\n } else {\n ;(async () => {\n try {\n const url = `${origin}/system/indexed-domains.json?pattern=${encodeURIComponent(patterns)}&limit=${limit}`\n const res = await fetch(url)\n if (!res.ok) throw new Error(`indexed-domains failed: ${res.status}`)\n const domains = await res.json()\n if (!domains.length) { status.textContent = 'No indexed domains found'; return }\n renderList(domains, null)\n writeCache(item, { domains })\n } catch (e) {\n status.textContent = `Error: ${e.message}`\n }\n })()\n }\n\n } else if (mode === 'similar') {\n const results = div.find('.sim-results')[0]\n\n const renderScored = (scored, ts) => {\n if (!scored.length) {\n status.textContent = `No similar pages found above threshold ${threshold}`\n return\n }\n results.innerHTML = `<h3>Similar Pages</h3><ul>${\n scored.map(({ domain, slug, title, score }) =>\n `<li>${simLink(domain, slug, title, score)}</li>`).join('')\n }</ul><p class=\"sim-count\">${scored.length} found ${scopeLabel}${cacheNote(ts)}</p>`\n status.style.display = 'none'\n }\n\n if (cache?.scored) {\n renderScored(cache.scored, cache.ts)\n } else {\n ;(async () => {\n try {\n const $page = div.parents('.page')\n const pageTitle = $page.find('.title').text().trim() || document.title\n const currentSlug = slugify(pageTitle)\n const currentDomain = window.location.hostname\n\n const domainEntries = await loadDomainEntries(specs, origin)\n const total = domainEntries.reduce((n, e) => n + e.pages.length, 0)\n status.textContent = `Searching ${total.toLocaleString()} pages\u2026`\n\n let qVec = await lookupPageVector(currentSlug, currentDomain)\n if (!qVec) {\n status.textContent = 'Embedding page (not yet indexed)\u2026'\n const pageText = $page.find('.item').map((_, el) => $(el).text().trim()).get().filter(Boolean).join('\\n')\n qVec = await getEmbedding(pageText || pageTitle, origin)\n }\n\n const scored = cosineScan(qVec, domainEntries, {\n threshold, limit, excludeSlug: currentSlug, excludeDomain: currentDomain,\n })\n renderScored(scored, null)\n if (scored.length) writeCache(item, { scored })\n } catch (e) {\n status.textContent = `Error: ${e.message}`\n }\n })()\n }\n\n } else if (mode === 'ghost') {\n // Ghost mode \u2014 fetch page-json from any URL and open it as a ghost page\n const btn = div.find('.sim-btn')[0]\n\n const doGhost = async () => {\n if (!ghostUrl) { status.textContent = 'No URL \u2014 GHOST needs a page-json URL'; return }\n btn.disabled = true\n status.textContent = 'Fetching\u2026'\n try {\n const res = await fetch(ghostUrl)\n if (!res.ok) throw new Error(`fetch failed: ${res.status}`)\n const page = await res.json()\n window.wiki.showResult(window.wiki.newPage(page), { $page: div.parents('.page') })\n status.textContent = ''\n } catch (e) {\n status.textContent = `Error: ${e.message}`\n } finally {\n btn.disabled = false\n }\n }\n\n btn.addEventListener('click', doGhost)\n\n } else if (mode === 'build') {\n // Build mode \u2014 trigger a semantic index build; result opens as a ghost page\n const btn = div.find('.sim-btn')[0]\n\n const doBuild = async () => {\n btn.disabled = true\n status.textContent = 'Building index\u2026 (may take a while for large scopes)'\n try {\n const domains = encodeURIComponent((specs.length ? specs : ['*']).join(','))\n const res = await fetch(\n `${origin}/system/build-index.json?domains=${domains}&force=${force ? 1 : 0}`)\n if (res.status === 501) {\n const body = await res.json().catch(() => ({}))\n throw new Error(body.hint || 'indexing runs on the farm indexer, not this server')\n }\n if (!res.ok) throw new Error(`build-index failed: ${res.status}`)\n const page = await res.json()\n window.wiki.showResult(window.wiki.newPage(page), { $page: div.parents('.page') })\n status.textContent = ''\n } catch (e) {\n status.textContent = `Error: ${e.message}`\n } finally {\n btn.disabled = false\n }\n }\n\n btn.addEventListener('click', doBuild)\n\n } else if (mode === 'report') {\n // Report mode \u2014 server-side ranked/bundled search, opens result as ghost page\n const input = div.find('.sim-input')[0]\n const btn = div.find('.sim-btn')[0]\n let readyLine = `Domains: ${specs.length ? specs.join(', ') : '*'}`\n\n // Preload the lightweight domain listing (counts only, no vectors) so the\n // status line shows scope and config before any search is issued.\n ;(async () => {\n try {\n const domains = await resolveDomains(specs.length ? specs : ['*'], origin)\n const pages = domains.reduce((n, d) => n + (d.page_count || 0), 0)\n readyLine = configSummary('Report ready', pages, domains.length)\n status.textContent = readyLine\n } catch (e) {\n status.textContent = `Domain listing unavailable: ${e.message}`\n }\n })()\n\n const doReport = async () => {\n const query = input.value.trim()\n if (!query) return\n btn.disabled = true\n status.textContent = 'Generating report\u2026'\n try {\n const body = { query, domains: specs.length ? specs : ['*'], limit, live }\n if (thresholdSet) body.threshold = threshold\n const res = await fetch(`${origin}/system/search-report.json`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n })\n if (!res.ok) throw new Error(`search-report failed: ${res.status}`)\n const page = await res.json()\n const pageObj = window.wiki.newPage(page)\n window.wiki.showResult(pageObj, { $page: div.parents('.page') })\n status.textContent = readyLine\n } catch (e) {\n status.textContent = `Error: ${e.message}`\n } finally {\n btn.disabled = false\n }\n }\n\n btn.addEventListener('click', doReport)\n input.addEventListener('keydown', e => { if (e.key === 'Enter') doReport() })\n\n } else if (mode === 'keyword') {\n // Keyword mode \u2014 galactic MiniSearch: the server reads each site's own\n // per-edit site-index.json; result opens as a ghost page.\n const input = div.find('.sim-input')[0]\n const btn = div.find('.sim-btn')[0]\n\n status.textContent = `Keyword search ready \u2014 domains: ${specs.length ? specs.join(', ') : '*'} \u00B7 limit ${limit}`\n\n const doKeyword = async () => {\n const query = input.value.trim()\n if (!query) return\n btn.disabled = true\n status.textContent = 'Searching live site indexes\u2026'\n try {\n const pattern = encodeURIComponent((specs.length ? specs : ['*']).join(','))\n const res = await fetch(\n `${origin}/system/farm-search.json?q=${encodeURIComponent(query)}&pattern=${pattern}&limit=${limit}`)\n if (!res.ok) throw new Error(`farm-search failed: ${res.status}`)\n const page = await res.json()\n window.wiki.showResult(window.wiki.newPage(page), { $page: div.parents('.page') })\n status.textContent = `Keyword search ready \u2014 domains: ${specs.length ? specs.join(', ') : '*'} \u00B7 limit ${limit}`\n } catch (e) {\n status.textContent = `Error: ${e.message}`\n } finally {\n btn.disabled = false\n }\n }\n\n btn.addEventListener('click', doKeyword)\n input.addEventListener('keydown', e => { if (e.key === 'Enter') doKeyword() })\n\n } else if (mode === 'author') {\n // Author mode \u2014 same search form but creates a ghost page from results\n const input = div.find('.sim-input')[0]\n const btn = div.find('.sim-btn')[0]\n const results = div.find('.sim-results')[0]\n let domainEntries = null\n\n ;(async () => {\n try {\n if (!cache) status.textContent = 'Resolving domains\u2026'\n domainEntries = await loadDomainEntries(specs, origin)\n const total = domainEntries.reduce((n, e) => n + e.pages.length, 0)\n status.textContent = configSummary('Author ready', total, domainEntries.length)\n } catch (e) {\n status.textContent = `Load error: ${e.message}`\n }\n })()\n\n const doAuthor = async () => {\n const query = input.value.trim()\n if (!query || !domainEntries) return\n btn.disabled = true\n status.textContent = 'Embedding query\u2026'\n results.innerHTML = ''\n try {\n const qVec = await getEmbedding(query, origin)\n const scored = cosineScan(qVec, domainEntries,\n { threshold, limit, excludeSlug: null, excludeDomain: null })\n\n // Unique titles for wiki-link list\n const seenTitles = new Set()\n const uniqueTitles = []\n for (const { title } of scored) {\n if (!seenTitles.has(title)) { seenTitles.add(title); uniqueTitles.push(title) }\n }\n\n const hexId = () => Math.floor(Math.random() * 0xffffffffffffffff).toString(16).padStart(16, '0')\n const primaryLines = uniqueTitles.map(t => `- [[${t}]]`).join('\\n')\n\n const story = [\n { type: 'markdown', id: hexId(), text: `# Similar Pages\\n\\n${primaryLines}` },\n { type: 'markdown', id: hexId(), text: '# Reference Links' },\n ...scored.map(({ domain, slug, title, score }) => ({\n type: 'reference', id: hexId(), site: domain, slug, title,\n text: `score ${score.toFixed(3)}`,\n })),\n ]\n\n const pageObj = window.wiki.newPage({ title: `${query} Results`, story, journal: [] })\n window.wiki.showResult(pageObj, { $page: div.parents('.page') })\n\n status.textContent = `${scored.length} pages found`\n writeCache(item, { scored, query })\n } catch (e) {\n status.textContent = `Error: ${e.message}`\n } finally {\n btn.disabled = false\n }\n }\n\n btn.addEventListener('click', doAuthor)\n input.addEventListener('keydown', e => { if (e.key === 'Enter') doAuthor() })\n\n } else {\n // Search form mode\n const input = div.find('.sim-input')[0]\n const btn = div.find('.sim-btn')[0]\n const results = div.find('.sim-results')[0]\n let domainEntries = null\n\n // Show cached results immediately while domains preload in background\n if (cache?.scored) {\n input.value = cache.query || ''\n results.innerHTML = cache.scored.map(({ domain, slug, title, score }) =>\n `<div class=\"sim-result\">${simLink(domain, slug, title, score)}</div>`).join('') +\n `<p class=\"sim-count\">Top ${cache.scored.length} for \"${cache.query || ''}\"${cacheNote(cache.ts)}</p>`\n status.textContent = ''\n }\n\n ;(async () => {\n try {\n if (!cache) status.textContent = 'Resolving domains\u2026'\n domainEntries = await loadDomainEntries(specs, origin)\n const total = domainEntries.reduce((n, e) => n + e.pages.length, 0)\n status.textContent = configSummary('Search ready', total, domainEntries.length)\n } catch (e) {\n status.textContent = `Load error: ${e.message}`\n }\n })()\n\n const doSearch = async () => {\n const query = input.value.trim()\n if (!query || !domainEntries) return\n btn.disabled = true\n status.textContent = 'Embedding query\u2026'\n results.innerHTML = ''\n try {\n const qVec = await getEmbedding(query, origin)\n const scored = cosineScan(qVec, domainEntries,\n { threshold, limit, excludeSlug: null, excludeDomain: null })\n results.innerHTML = scored.map(({ domain, slug, title, score }) =>\n `<div class=\"sim-result\">${simLink(domain, slug, title, score)}</div>`).join('') +\n `<p class=\"sim-count\">Top ${scored.length} for \"${query}\"</p>`\n status.textContent = ''\n writeCache(item, { scored, query })\n } catch (e) {\n status.textContent = `Error: ${e.message}`\n } finally {\n btn.disabled = false\n }\n }\n\n btn.addEventListener('click', doSearch)\n input.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch() })\n }\n}\n\n// \u2500\u2500 Register item type with the wiki \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nif (typeof window !== 'undefined') {\n window.plugins = window.plugins || {}\n window.plugins.similarity = { emit, bind }\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["SIMILAR_THRESHOLDS", "DEFAULT_THRESHOLD", "DEFAULT_LIMIT", "parseDSL", "text", "specs", "threshold", "limit", "mode", "live", "force", "ghostUrl", "label", "isCmd", "upper", "kw", "val", "line", "raw", "level", "tv", "isGlob", "spec", "isScope", "slugify", "title", "domainCache", "resolveDomainsForSpec", "origin", "url", "res", "list", "resolveDomains", "seen", "result", "item", "vectorCache", "vectorUrl", "domain", "loadVectors", "data", "getEmbedding", "lookupPageVector", "slug", "entry", "p", "cosineScan", "queryVec", "domainEntries", "excludeSlug", "excludeDomain", "results", "pages", "vector", "dot", "i", "a", "loadDomainEntries", "domains", "e", "cacheKey", "id", "readCache", "c", "writeCache", "cacheAge", "ts", "s", "STYLES", "siteFlag", "score", "simLink", "emit", "div", "btnLabel", "bind", "thresholdSet", "status", "cache", "configSummary", "verb", "nDomains", "parts", "$a", "$page", "scopeLabel", "cacheNote", "listDiv", "patterns", "renderList", "totalPages", "n", "d", "page_count", "renderScored", "scored", "pageTitle", "currentSlug", "currentDomain", "total", "qVec", "pageText", "_", "el", "btn", "doGhost", "page", "doBuild", "body", "input", "readyLine", "doReport", "query", "pageObj", "doKeyword", "pattern", "doAuthor", "seenTitles", "uniqueTitles", "hexId", "primaryLines", "t", "story", "doSearch"]
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-plugin-similarity",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "description": "Federated Wiki plugin — semantic similarity search across wiki domains using prebuilt vector indexes",
5
5
  "keywords": [
6
6
  "wiki",
@@ -41,6 +41,10 @@
41
41
  "test": "node --test",
42
42
  "prepublishOnly": "npm run build"
43
43
  },
44
+ "dependencies": {
45
+ "@xenova/transformers": "^2.17.2",
46
+ "minisearch": "^7.2.0"
47
+ },
44
48
  "devDependencies": {
45
49
  "esbuild": "^0.25.0"
46
50
  }
@@ -9,7 +9,7 @@
9
9
  {
10
10
  "type": "markdown",
11
11
  "id": "a3f7c8d2e1b94056",
12
- "text": "**Version:** 0.4.3[GitHub](https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-similarity) · [npm](https://www.npmjs.com/package/wiki-plugin-similarity)"
12
+ "text": "**Version:** 0.5.0server-side search: everything runs same-origin on the wiki host, no external API needed."
13
13
  },
14
14
  {
15
15
  "type": "similarity",
@@ -44,7 +44,7 @@
44
44
  {
45
45
  "type": "markdown",
46
46
  "id": "4f91a3b2d8e05c11",
47
- "text": "**Search** (default) — interactive form; results appear inline as scored links. Shift-click a result to append it at the end of the lineup instead of truncating.\n\n**Similar** — runs on page load using the current page's pre-computed vector (no embed call if already indexed). Ranked *Similar Pages* list.\n\n**List** — table of indexed domains with page counts; supports scopes, globs, and `LIMIT`.\n\n**Author** — same search, but opens a ghost page with `# Similar Pages` ([[wiki links]], deduplicated) and `# Reference Links` (all results as reference items with scores).\n\n**Report** — posts to the farm API's `/search-report`: candidates from semantic scan ∪ title matching, stubs dropped, forks and near-duplicates bundled under the strongest copy with a one-click lineup link, ranked by semantic × richness × recency + title boost. Opens as a ghost page. *(localhost onlyneeds the FastAPI service.)*\n\n**Build** — a button that triggers semantic index building via `/build-index.json`; the build report opens as a ghost page. Add `FORCE` to re-embed fresh domains. *(localhost only.)*\n\n**Ghost** — a generic button: `GHOST <url>` fetches any endpoint returning fedwiki page JSON and opens it as a ghost page. Caption set with `BUTTON <text>`. Example: the *Farm Roster Report* button fetches `/farm-rosters.json`, which rosters every farm domain by scope with per-section error rosters for unserved domains."
47
+ "text": "**Search** (default) — interactive form; results appear inline as scored links. Shift-click a result to append it at the end of the lineup instead of truncating.\n\n**Similar** — runs on page load using the current page's pre-computed vector (no embed call if already indexed). Ranked *Similar Pages* list.\n\n**List** — table of indexed domains with page counts; supports scopes, globs, and `LIMIT`.\n\n**Author** — same search, but opens a ghost page with `# Similar Pages` ([[wiki links]], deduplicated) and `# Reference Links` (all results as reference items with scores).\n\n**Report** — posts to the plugin's own `/system/search-report.json`: candidates from semantic scan ∪ title matching, stubs dropped, forks and near-duplicates bundled under the strongest copy with a one-click lineup link, ranked by semantic × richness × recency + title boost. Opens as a ghost page.\n\n**Keyword** — galactic MiniSearch via `/system/farm-search.json`: the server reads each site's own per-edit `site-index.json` — the indexes fedwiki already maintains — plus sitemap titles. No separate index is built. Opens as a ghost page.\n\n**Build** — a button that asks the farm indexer to rebuild semantic indexes. The wiki server never embeds page corpora itself; it proxies to `WIKI_INDEXER_URL` when configured and otherwise explains that indexing runs on the farm indexer. Add `FORCE` to re-embed fresh domains.\n\n**Ghost** — a generic button: `GHOST <url>` fetches any endpoint returning fedwiki page JSON and opens it as a ghost page. Caption set with `BUTTON <text>`."
48
48
  },
49
49
  {
50
50
  "type": "markdown",
@@ -64,7 +64,7 @@
64
64
  {
65
65
  "type": "markdown",
66
66
  "id": "4b1bbe95544548eb",
67
- "text": "Pages are pre-indexed as 384-dimensional embedding vectors using BAAI/bge-small-en-v1.5 via fastembed. The plugin loads `semantic-vectors.json` index files directly in the browser and scores them with cosine similarity in JavaScript — no remote search engine required.\n\nThe server component registers `/system/indexed-domains.json`, `/system/semantic-vectors.json`, and `/system/embed.json` routes, and understands the scope keywords. See [[Similarity Indexing]] for building indices and [[Build Semantic Index]] for triggering builds from the wiki."
67
+ "text": "Pages are pre-indexed as 384-dimensional embedding vectors (BAAI/bge-small-en-v1.5, unit-normalised) by the farm indexer, which writes `semantic-vectors.json` into each domain's `status/` folder. The plugin loads these indexes and scores them with a dot product — no remote search engine required.\n\nThe server component registers same-origin `/system` routes: `indexed-domains.json`, `semantic-vectors.json`, `embed.json` (query embedding **in-process** via transformers.js — the same model the indexes were built with, so scores match), `search-report.json` (the full ranked report pipeline), `farm-search.json` (keyword search over the sites' own per-edit MiniSearch indexes), and `build-index.json` (indexer proxy). Because every route runs on the wiki host itself, search works for any visitor — no HomeLab services needed. See [[Similarity Indexing]] for building indices and [[Build Semantic Index]] for triggering builds from the wiki."
68
68
  }
69
69
  ],
70
70
  "journal": [
@@ -82,7 +82,37 @@
82
82
  },
83
83
  "date": 1781133629000,
84
84
  "certificate": "from marvin"
85
+ },
86
+ {
87
+ "type": "edit",
88
+ "id": "a3f7c8d2e1b94056",
89
+ "item": {
90
+ "type": "markdown",
91
+ "id": "a3f7c8d2e1b94056",
92
+ "text": "**Version:** 0.5.0 — server-side search: everything runs same-origin on the wiki host, no external API needed."
93
+ },
94
+ "date": 1783773290699
95
+ },
96
+ {
97
+ "type": "edit",
98
+ "id": "4f91a3b2d8e05c11",
99
+ "item": {
100
+ "type": "markdown",
101
+ "id": "4f91a3b2d8e05c11",
102
+ "text": "**Search** (default) — interactive form; results appear inline as scored links. Shift-click a result to append it at the end of the lineup instead of truncating.\n\n**Similar** — runs on page load using the current page's pre-computed vector (no embed call if already indexed). Ranked *Similar Pages* list.\n\n**List** — table of indexed domains with page counts; supports scopes, globs, and `LIMIT`.\n\n**Author** — same search, but opens a ghost page with `# Similar Pages` ([[wiki links]], deduplicated) and `# Reference Links` (all results as reference items with scores).\n\n**Report** — posts to the plugin's own `/system/search-report.json`: candidates from semantic scan ∪ title matching, stubs dropped, forks and near-duplicates bundled under the strongest copy with a one-click lineup link, ranked by semantic × richness × recency + title boost. Opens as a ghost page.\n\n**Keyword** — galactic MiniSearch via `/system/farm-search.json`: the server reads each site's own per-edit `site-index.json` — the indexes fedwiki already maintains — plus sitemap titles. No separate index is built. Opens as a ghost page.\n\n**Build** — a button that asks the farm indexer to rebuild semantic indexes. The wiki server never embeds page corpora itself; it proxies to `WIKI_INDEXER_URL` when configured and otherwise explains that indexing runs on the farm indexer. Add `FORCE` to re-embed fresh domains.\n\n**Ghost** — a generic button: `GHOST <url>` fetches any endpoint returning fedwiki page JSON and opens it as a ghost page. Caption set with `BUTTON <text>`."
103
+ },
104
+ "date": 1783773290699
105
+ },
106
+ {
107
+ "type": "edit",
108
+ "id": "4b1bbe95544548eb",
109
+ "item": {
110
+ "type": "markdown",
111
+ "id": "4b1bbe95544548eb",
112
+ "text": "Pages are pre-indexed as 384-dimensional embedding vectors (BAAI/bge-small-en-v1.5, unit-normalised) by the farm indexer, which writes `semantic-vectors.json` into each domain's `status/` folder. The plugin loads these indexes and scores them with a dot product — no remote search engine required.\n\nThe server component registers same-origin `/system` routes: `indexed-domains.json`, `semantic-vectors.json`, `embed.json` (query embedding **in-process** via transformers.js — the same model the indexes were built with, so scores match), `search-report.json` (the full ranked report pipeline), `farm-search.json` (keyword search over the sites' own per-edit MiniSearch indexes), and `build-index.json` (indexer proxy). Because every route runs on the wiki host itself, search works for any visitor — no HomeLab services needed. See [[Similarity Indexing]] for building indices and [[Build Semantic Index]] for triggering builds from the wiki."
113
+ },
114
+ "date": 1783773290699
85
115
  }
86
116
  ],
87
117
  "plugin": "similarity"
88
- }
118
+ }
@@ -0,0 +1,48 @@
1
+ // wiki-plugin-similarity — in-process query embedder (CommonJS)
2
+ //
3
+ // Reproduces the farm indexer's embeddings (fastembed, BAAI/bge-small-en-v1.5,
4
+ // 384-dim, L2-normalised, NO query prefix) using transformers.js — pure
5
+ // JS/WASM ONNX, no native dependencies, so it runs inside any wiki-server
6
+ // install including the public farm's container.
7
+ //
8
+ // Parity notes (verified against the FastAPI /embed reference):
9
+ // - pooling and quantisation are configurable via env because they are the
10
+ // two knobs that can desync scores from the prebuilt semantic-vectors.json:
11
+ // WIKI_EMBED_POOLING cls | mean (default cls — BGE models use CLS)
12
+ // WIKI_EMBED_QUANTIZED 1 | 0 (default 0 — fp32 matches fastembed)
13
+ // - never add an instruction prefix: the index was built with plain .embed().
14
+ //
15
+ // Model files download on first use (~130MB fp32) into WIKI_MODEL_CACHE if set,
16
+ // else transformers.js's default cache. Subsequent runs are offline.
17
+
18
+ const MODEL = process.env.WIKI_EMBED_MODEL || 'Xenova/bge-small-en-v1.5'
19
+ const POOLING = process.env.WIKI_EMBED_POOLING || 'cls'
20
+ const QUANTIZED = process.env.WIKI_EMBED_QUANTIZED === '1'
21
+
22
+ let extractorPromise = null
23
+
24
+ const getExtractor = () => {
25
+ if (!extractorPromise) {
26
+ extractorPromise = (async () => {
27
+ // Dynamic import: @xenova/transformers is ESM-only; this file must stay CJS.
28
+ const { pipeline, env } = await import('@xenova/transformers')
29
+ if (process.env.WIKI_MODEL_CACHE) env.cacheDir = process.env.WIKI_MODEL_CACHE
30
+ return pipeline('feature-extraction', MODEL, { quantized: QUANTIZED })
31
+ })()
32
+ extractorPromise.catch(() => { extractorPromise = null }) // allow retry
33
+ }
34
+ return extractorPromise
35
+ }
36
+
37
+ // Embed one text → plain number[384], unit-normalised.
38
+ const embed = async text => {
39
+ const extractor = await getExtractor()
40
+ const out = await extractor(text, { pooling: POOLING, normalize: true })
41
+ return Array.from(out.data)
42
+ }
43
+
44
+ // Warm the model in the background (call at startup; failures just defer to
45
+ // first request).
46
+ const warm = () => { getExtractor().catch(() => {}) }
47
+
48
+ module.exports = { embed, warm }
@@ -0,0 +1,102 @@
1
+ // wiki-plugin-similarity — shared farm helpers (CommonJS, see server/package.json)
2
+ //
3
+ // Used by server.js, search-report.js and farm-search.js. A "farms" list is
4
+ // [[rootPath, kind], ...] where kind is 'local' (the primary farm this wiki
5
+ // serves from) or 'public' (extra farms, e.g. the Nextcloud mirror).
6
+
7
+ const fs = require('node:fs')
8
+ const path = require('node:path')
9
+
10
+ // ── Glob matching — supports * and ?, no path separator semantics ─────────────
11
+
12
+ const globMatch = (pattern, str) => {
13
+ const p = pattern.length
14
+ const s = str.length
15
+ const dp = Array.from({ length: p + 1 }, () => new Array(s + 1).fill(false))
16
+ dp[0][0] = true
17
+ for (let i = 1; i <= p; i++) {
18
+ if (pattern[i - 1] === '*') dp[i][0] = dp[i - 1][0]
19
+ }
20
+ for (let i = 1; i <= p; i++) {
21
+ for (let j = 1; j <= s; j++) {
22
+ if (pattern[i - 1] === '*') {
23
+ dp[i][j] = dp[i - 1][j] || dp[i][j - 1]
24
+ } else if (pattern[i - 1] === '?' || pattern[i - 1] === str[j - 1]) {
25
+ dp[i][j] = dp[i - 1][j - 1]
26
+ }
27
+ }
28
+ }
29
+ return dp[p][s]
30
+ }
31
+
32
+ // ── Scope keywords (exact uppercase, per DSL convention) ──────────────────────
33
+ // * all farms
34
+ // PUBLIC domains in 'public' farms (Nextcloud mirror)
35
+ // LOCAL domains in the primary ('local') farm
36
+ // PRIVATE public domains marked "restricted": true in a farm config
37
+
38
+ const loadRestricted = publicFarms => {
39
+ const restricted = new Set()
40
+ for (const farm of publicFarms) {
41
+ let files
42
+ try { files = fs.readdirSync(farm) } catch { continue }
43
+ for (const f of files) {
44
+ if (!/^config-.*\.json$/.test(f)) continue
45
+ try {
46
+ const cfg = JSON.parse(fs.readFileSync(path.join(farm, f), 'utf8'))
47
+ for (const [domain, opts] of Object.entries(cfg.wikiDomains || {})) {
48
+ if (opts && opts.restricted) restricted.add(domain)
49
+ }
50
+ } catch { /* ignore malformed config */ }
51
+ }
52
+ }
53
+ return restricted
54
+ }
55
+
56
+ const matchesAny = (domain, kind, patterns, restricted) =>
57
+ patterns.some(p => {
58
+ if (p === '*') return true
59
+ if (p === 'PUBLIC') return kind === 'public'
60
+ if (p === 'LOCAL') return kind === 'local'
61
+ if (p === 'PRIVATE') return kind === 'public' && restricted.has(domain)
62
+ return globMatch(p, domain)
63
+ })
64
+
65
+ // ── Domain listing ────────────────────────────────────────────────────────────
66
+ // List domains across farms matching the patterns, optionally requiring a file
67
+ // (relative to the domain dir) to exist. First farm wins on duplicate names.
68
+ // Returns [{farm, kind, domain}].
69
+
70
+ const listDomains = (farms, patterns, restricted, requireFile = null) => {
71
+ const seen = new Set()
72
+ const out = []
73
+ for (const [farm, kind] of farms) {
74
+ let entries
75
+ try { entries = fs.readdirSync(farm, { withFileTypes: true }) } catch { continue }
76
+ for (const ent of entries) {
77
+ if (!ent.isDirectory()) continue
78
+ const domain = ent.name
79
+ if (seen.has(domain)) continue
80
+ if (!matchesAny(domain, kind, patterns, restricted)) continue
81
+ if (requireFile) {
82
+ try { fs.accessSync(path.join(farm, domain, requireFile), fs.constants.F_OK) }
83
+ catch { continue }
84
+ }
85
+ seen.add(domain)
86
+ out.push({ farm, kind, domain })
87
+ }
88
+ }
89
+ out.sort((a, b) => a.domain.localeCompare(b.domain))
90
+ return out
91
+ }
92
+
93
+ // First existing path for domain + relative sub-path across farm roots.
94
+ const findInFarms = (farms, domain, relPath) => {
95
+ for (const [farm] of farms) {
96
+ const full = path.join(farm, domain, relPath)
97
+ try { fs.accessSync(full, fs.constants.F_OK); return full } catch { /* next */ }
98
+ }
99
+ return null
100
+ }
101
+
102
+ module.exports = { globMatch, loadRestricted, matchesAny, listDomains, findInFarms }
@@ -0,0 +1,122 @@
1
+ // wiki-plugin-similarity — galactic keyword search (CommonJS)
2
+ //
3
+ // Fedwiki maintains a serialized MiniSearch index per site: the wiki-server
4
+ // updates {domain}/status/site-index.json on every edit. This module READS
5
+ // those indexes across every domain on the farm(s) — no index building of any
6
+ // kind. On the farm server the files are per-edit fresh because the server
7
+ // itself writes them.
8
+ //
9
+ // The server-built index stores no fields (storedFields is empty), so display
10
+ // titles come from each domain's status/sitemap.json.
11
+ //
12
+ // Loaded indexes are cached per domain and invalidated by file mtime.
13
+
14
+ const fs = require('node:fs')
15
+ const path = require('node:path')
16
+ const crypto = require('node:crypto')
17
+
18
+ const MiniSearch = require('minisearch')
19
+ const { listDomains } = require('./farm-lib')
20
+
21
+ // site-index.json was serialized with these options; loadJSON needs them.
22
+ const MS_OPTIONS = { fields: ['title', 'content'] }
23
+
24
+ const makeId = () => crypto.randomBytes(8).toString('hex')
25
+
26
+ // ── Per-domain index cache, mtime-invalidated ─────────────────────────────────
27
+
28
+ const cache = new Map() // domain → {mtime, ms, titles, synopses}
29
+
30
+ const loadDomainIndex = (farm, domain) => {
31
+ const indexPath = path.join(farm, domain, 'status', 'site-index.json')
32
+ let mtime
33
+ try { mtime = fs.statSync(indexPath).mtimeMs } catch { return null }
34
+
35
+ const hit = cache.get(domain)
36
+ if (hit && hit.mtime === mtime) return hit
37
+
38
+ let ms
39
+ try {
40
+ ms = MiniSearch.loadJSON(fs.readFileSync(indexPath, 'utf8'), MS_OPTIONS)
41
+ } catch { return null }
42
+
43
+ // Titles + synopses from the sitemap (the index stores no fields).
44
+ const titles = new Map()
45
+ const synopses = new Map()
46
+ try {
47
+ const sitemap = JSON.parse(fs.readFileSync(
48
+ path.join(farm, domain, 'status', 'sitemap.json'), 'utf8'))
49
+ for (const p of sitemap) {
50
+ titles.set(p.slug, p.title)
51
+ if (p.synopsis) synopses.set(p.slug, p.synopsis)
52
+ }
53
+ } catch { /* titles fall back to slugs */ }
54
+
55
+ const entry = { mtime, ms, titles, synopses }
56
+ cache.set(domain, entry)
57
+ return entry
58
+ }
59
+
60
+ // ── Search across domains ─────────────────────────────────────────────────────
61
+
62
+ const searchFarm = (farms, patterns, restricted, query, limit) => {
63
+ const domains = listDomains(farms, patterns, restricted, 'status/site-index.json')
64
+ const results = []
65
+ let searched = 0
66
+ for (const { farm, domain } of domains) {
67
+ const entry = loadDomainIndex(farm, domain)
68
+ if (!entry) continue
69
+ searched += 1
70
+ let hits
71
+ try { hits = entry.ms.search(query, { boost: { title: 2 } }) } catch { continue }
72
+ for (const h of hits) {
73
+ results.push({
74
+ domain,
75
+ slug: String(h.id),
76
+ title: entry.titles.get(String(h.id)) || String(h.id),
77
+ synopsis: entry.synopses.get(String(h.id)) || '',
78
+ score: h.score,
79
+ })
80
+ }
81
+ }
82
+ results.sort((a, b) => b.score - a.score)
83
+ return { results: results.slice(0, limit), searched, matched: results.length }
84
+ }
85
+
86
+ // ── Report page JSON (ghost-page ready, mirrors search-report style) ──────────
87
+
88
+ const keywordReportPage = (query, { results, searched, matched }, limit, specs) => {
89
+ const story = [{
90
+ type: 'markdown', id: makeId(),
91
+ text: `Keyword search for **${query}** — searched the live site indexes of ` +
92
+ `${searched} domains; ${matched} matches, top ${Math.min(limit, results.length)} shown.\n\n` +
93
+ `<small>Config — domains: ${(specs || ['*']).join(', ')}; limit: ${limit}. ` +
94
+ `Reads each site's own per-edit MiniSearch index (site-index.json) — ` +
95
+ `no separate index is built.</small>`,
96
+ }]
97
+
98
+ const seenTitles = new Set()
99
+ const uniqueTitles = results.map(r => r.title).filter(t => {
100
+ if (seenTitles.has(t)) return false
101
+ seenTitles.add(t)
102
+ return true
103
+ })
104
+ if (uniqueTitles.length) {
105
+ story.push({
106
+ type: 'markdown', id: makeId(),
107
+ text: uniqueTitles.map(t => `- [[${t}]]`).join('\n'),
108
+ })
109
+ }
110
+
111
+ story.push({ type: 'markdown', id: makeId(), text: '# Results' })
112
+ for (const r of results) {
113
+ story.push({
114
+ type: 'reference', id: makeId(),
115
+ site: r.domain, slug: r.slug, title: r.title,
116
+ text: r.synopsis || `score ${r.score.toFixed(2)}`,
117
+ })
118
+ }
119
+ return { title: `${query} Keyword Search`, story }
120
+ }
121
+
122
+ module.exports = { searchFarm, keywordReportPage }
Binary file
package/server/server.js CHANGED
@@ -1,22 +1,31 @@
1
1
  // wiki-plugin-similarity — server-side component
2
2
  //
3
- // Registers three routes with the wiki's Express app via the startServer(params)
4
- // hook in wiki-server/lib/plugins.js.
3
+ // Registers same-origin /system routes with the wiki's Express app via the
4
+ // startServer(params) hook in wiki-server/lib/plugins.js. Everything a search
5
+ // needs runs in-process on whatever host serves the wiki — no HomeLab FastAPI
6
+ // dependency — so club members on the public farm get working search.
5
7
  //
6
8
  // Routes:
7
- // GET /system/indexed-domains.json?pattern=glob1,glob2
8
- // Returns [{domain, page_count}] for all domains whose semantic-vectors.json
9
- // exists and whose name matches any of the supplied glob patterns.
9
+ // GET /system/indexed-domains.json?pattern=glob1,glob2
10
+ // [{domain, page_count}] for domains with a semantic-vectors.json.
11
+ // GET /system/semantic-vectors.json[?domain=]
12
+ // Serves {farm}/{domain}/status/semantic-vectors.json.
13
+ // GET /system/embed.json?text=…
14
+ // 384-dim unit vector via the in-process transformers.js embedder
15
+ // (BAAI/bge-small-en-v1.5 — same model the indexes were built with).
16
+ // Set WIKI_EMBED_URL to proxy to an external embedder instead.
17
+ // POST /system/search-report.json {query, domains, limit, threshold, live}
18
+ // Ranked, stub-filtered, fork-bundled semantic report (page JSON).
19
+ // GET /system/farm-search.json?q=…&pattern=…&limit=…
20
+ // Galactic keyword search — reads each site's own per-edit MiniSearch
21
+ // index (status/site-index.json). No index building.
22
+ // GET /system/build-index.json?domains=…&force=…
23
+ // Proxy to the farm indexer (WIKI_INDEXER_URL) when configured; heavy
24
+ // embedding is the indexer's job (Pi5 on the Hitchhikers farm), never
25
+ // the wiki server's.
10
26
  //
11
- // GET /system/semantic-vectors.json
12
- // Serves {farm}/{req.hostname}/status/semantic-vectors.json.
13
- // req.hostname selects the domain in a fedwiki farm.
14
- //
15
- // GET /system/embed?text=…
16
- // Proxies to the local FastAPI /embed endpoint and returns {vector:[…]}.
17
- // Requires the FastAPI server to be running on EMBED_URL (default localhost:8000).
18
- //
19
- // Farm root is derived from argv.status which is {farm}/{domain}/status.
27
+ // Farm root is derived from argv.status ({farm}/{domain}/status); extra farm
28
+ // roots come from WIKI_EXTRA_FARMS (colon-separated absolute paths).
20
29
  //
21
30
  // CommonJS on purpose (see sibling server/package.json): wiki-server's older
22
31
  // require() loader throws ERR_REQUIRE_ESM on an ESM server.js and swallows the
@@ -27,118 +36,19 @@ const fs = require('node:fs')
27
36
  const path = require('node:path')
28
37
  const http = require('node:http')
29
38
 
30
- const EMBED_URL = process.env.WIKI_EMBED_URL || 'http://localhost:8000/embed'
31
- // Optional additional farm roots to search when a domain's vectors aren't found
32
- // in the primary farm. Colon-separated list of absolute paths, e.g.:
33
- // WIKI_EXTRA_FARMS=/Users/david/Nextcloud/fedwiki:/Users/david/other-farm
34
- const EXTRA_FARMS = (process.env.WIKI_EXTRA_FARMS || '').split(':').filter(Boolean)
35
-
36
- // ── Glob matching ─────────────────────────────────────────────────────────────
37
- // Supports * (any chars) and ? (one char). No path separator semantics needed.
38
-
39
- const globMatch = (pattern, str) => {
40
- const p = pattern.length
41
- const s = str.length
42
- const dp = Array.from({ length: p + 1 }, () => new Array(s + 1).fill(false))
43
- dp[0][0] = true
44
- for (let i = 1; i <= p; i++) {
45
- if (pattern[i - 1] === '*') dp[i][0] = dp[i - 1][0]
46
- }
47
- for (let i = 1; i <= p; i++) {
48
- for (let j = 1; j <= s; j++) {
49
- if (pattern[i - 1] === '*') {
50
- dp[i][j] = dp[i - 1][j] || dp[i][j - 1]
51
- } else if (pattern[i - 1] === '?' || pattern[i - 1] === str[j - 1]) {
52
- dp[i][j] = dp[i - 1][j - 1]
53
- }
54
- }
55
- }
56
- return dp[p][s]
57
- }
58
-
59
- // Scope keywords (exact uppercase, per DSL convention):
60
- // * all farms
61
- // PUBLIC domains in the extra farms (Nextcloud mirror)
62
- // LOCAL domains in the primary (localhost) farm
63
- // PRIVATE public domains marked "restricted": true in the farm config
64
-
65
- // Collect restricted domains from config-*.json files at each public farm root
66
- const loadRestricted = () => {
67
- const restricted = new Set()
68
- for (const farm of EXTRA_FARMS) {
69
- let files
70
- try { files = fs.readdirSync(farm) } catch { continue }
71
- for (const f of files) {
72
- if (!/^config-.*\.json$/.test(f)) continue
73
- try {
74
- const cfg = JSON.parse(fs.readFileSync(path.join(farm, f), 'utf8'))
75
- for (const [domain, opts] of Object.entries(cfg.wikiDomains || {})) {
76
- if (opts && opts.restricted) restricted.add(domain)
77
- }
78
- } catch { /* ignore malformed config */ }
79
- }
80
- }
81
- return restricted
82
- }
83
- const RESTRICTED = loadRestricted()
84
-
85
- // kind: 'local' for the primary farm, 'public' for extra farms
86
- const matchesAny = (domain, kind, patterns) =>
87
- patterns.some(p => {
88
- if (p === '*') return true
89
- if (p === 'PUBLIC') return kind === 'public'
90
- if (p === 'LOCAL') return kind === 'local'
91
- if (p === 'PRIVATE') return kind === 'public' && RESTRICTED.has(domain)
92
- return globMatch(p, domain)
93
- })
94
-
95
- // ── Multi-farm helpers ────────────────────────────────────────────────────────
96
-
97
- // Return the first path that exists across all farm roots for a given domain
98
- // and relative sub-path (e.g. 'status/semantic-vectors.json').
99
- const findInFarms = (farmRoot, domain, relPath) => {
100
- const farms = [farmRoot, ...EXTRA_FARMS]
101
- for (const farm of farms) {
102
- const full = path.join(farm, domain, relPath)
103
- try { fs.accessSync(full, fs.constants.F_OK); return full } catch { /* try next */ }
104
- }
105
- return null
106
- }
107
-
108
- // ── Discover indexed domains ───────────────────────────────────────────────────
39
+ const { loadRestricted, matchesAny, listDomains, findInFarms } = require('./farm-lib')
40
+ const embedder = require('./embedder')
41
+ const { buildReport } = require('./search-report')
42
+ const { searchFarm, keywordReportPage } = require('./farm-search')
109
43
 
110
- const findIndexedDomains = (farmRoot, patterns) => {
111
- // primary farm is 'local'; extra farms (Nextcloud mirror) are 'public'
112
- const farms = [[farmRoot, 'local'], ...EXTRA_FARMS.map(f => [f, 'public'])]
113
- const seen = new Set()
114
- const results = []
115
-
116
- for (const [farm, kind] of farms) {
117
- let entries
118
- try { entries = fs.readdirSync(farm, { withFileTypes: true }) } catch { continue }
119
-
120
- for (const ent of entries) {
121
- if (!ent.isDirectory()) continue
122
- const domain = ent.name
123
- if (seen.has(domain)) continue // first farm wins
124
- if (!matchesAny(domain, kind, patterns)) continue
125
- const vecFile = path.join(farm, domain, 'status', 'semantic-vectors.json')
126
- try { fs.accessSync(vecFile, fs.constants.F_OK) } catch { continue }
127
- seen.add(domain)
128
- let pageCount = null
129
- try {
130
- const pages = JSON.parse(fs.readFileSync(vecFile, 'utf8'))
131
- pageCount = Array.isArray(pages) ? pages.length : null
132
- } catch { /* ignore */ }
133
- results.push({ domain, page_count: pageCount })
134
- }
135
- }
44
+ // Optional external embedder (proxy) unset means embed in-process.
45
+ const EMBED_URL = process.env.WIKI_EMBED_URL || null
46
+ // Farm indexer for BUILD requests (HomeLab FastAPI, or unset on the public farm).
47
+ const INDEXER_URL = process.env.WIKI_INDEXER_URL || null
48
+ // Optional additional farm roots, colon-separated absolute paths.
49
+ const EXTRA_FARMS = (process.env.WIKI_EXTRA_FARMS || '').split(':').filter(Boolean)
136
50
 
137
- results.sort((a, b) => a.domain.localeCompare(b.domain))
138
- return results
139
- }
140
-
141
- // ── Fetch helper (node:http, avoids fetch() version concerns) ─────────────────
51
+ // ── HTTP helpers ──────────────────────────────────────────────────────────────
142
52
 
143
53
  const postJson = (url, body) =>
144
54
  new Promise((resolve, reject) => {
@@ -159,7 +69,7 @@ const postJson = (url, body) =>
159
69
  res.on('data', chunk => { data += chunk })
160
70
  res.on('end', () => {
161
71
  try { resolve(JSON.parse(data)) }
162
- catch (e) { reject(new Error(`embed response parse error: ${e.message}`)) }
72
+ catch (e) { reject(new Error(`response parse error: ${e.message}`)) }
163
73
  })
164
74
  })
165
75
  req.on('error', reject)
@@ -167,31 +77,64 @@ const postJson = (url, body) =>
167
77
  req.end()
168
78
  })
169
79
 
80
+ const getJson = url =>
81
+ new Promise((resolve, reject) => {
82
+ const u = new URL(url)
83
+ http.get(u, res => {
84
+ let data = ''
85
+ res.on('data', chunk => { data += chunk })
86
+ res.on('end', () => {
87
+ try { resolve(JSON.parse(data)) }
88
+ catch (e) { reject(new Error(`response parse error: ${e.message}`)) }
89
+ })
90
+ }).on('error', reject)
91
+ })
92
+
93
+ // Read a JSON request body without assuming a body-parser is mounted.
94
+ const readBody = req =>
95
+ new Promise((resolve, reject) => {
96
+ if (req.body && typeof req.body === 'object') return resolve(req.body)
97
+ let data = ''
98
+ req.on('data', chunk => { data += chunk })
99
+ req.on('end', () => {
100
+ try { resolve(data ? JSON.parse(data) : {}) }
101
+ catch (e) { reject(new Error(`invalid JSON body: ${e.message}`)) }
102
+ })
103
+ req.on('error', reject)
104
+ })
105
+
170
106
  // ── startServer — called by wiki-server/lib/plugins.js ────────────────────────
171
107
 
172
108
  const startServer = ({ argv, app }) => {
173
109
  // Farm root: argv.status = {farm}/{thisDomain}/status → go up two levels
174
110
  const farmRoot = path.dirname(path.dirname(argv.status))
111
+ // primary farm is 'local'; extra farms (Nextcloud mirror) are 'public'
112
+ const farms = [[farmRoot, 'local'], ...EXTRA_FARMS.map(f => [f, 'public'])]
113
+ const restricted = loadRestricted(EXTRA_FARMS)
114
+ const ctx = { farms, restricted, embed: embedText }
175
115
 
176
- console.log('[wiki-plugin-similarity] registering /system routes, farm:', farmRoot)
116
+ console.log('[wiki-plugin-similarity] registering /system routes, farms:',
117
+ farms.map(([f]) => f).join(', '))
118
+
119
+ // Warm the embedding model in the background so the first query is fast.
120
+ if (!EMBED_URL) embedder.warm()
121
+
122
+ async function embedText(text) {
123
+ if (EMBED_URL) return (await postJson(EMBED_URL, { text })).vector
124
+ return embedder.embed(text)
125
+ }
177
126
 
178
- // CORS helper
179
127
  const cors = res => {
180
128
  res.setHeader('Access-Control-Allow-Origin', '*')
181
- res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
129
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
182
130
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
183
131
  }
184
132
 
185
- // ── OPTIONS pre-flight ─────────────────────────────────────────────────────
186
- app.options('/system/indexed-domains.json', (req, res) => {
187
- cors(res); res.sendStatus(204)
188
- })
189
- app.options('/system/semantic-vectors.json', (req, res) => {
190
- cors(res); res.sendStatus(204)
191
- })
192
- app.options('/system/embed.json', (req, res) => {
193
- cors(res); res.sendStatus(204)
194
- })
133
+ for (const route of ['/system/indexed-domains.json', '/system/semantic-vectors.json',
134
+ '/system/embed.json', '/system/search-report.json',
135
+ '/system/farm-search.json', '/system/build-index.json']) {
136
+ app.options(route, (req, res) => { cors(res); res.sendStatus(204) })
137
+ }
195
138
 
196
139
  // ── GET /system/indexed-domains.json?pattern=glob1,glob2 ──────────────────
197
140
  app.get('/system/indexed-domains.json', (req, res) => {
@@ -199,22 +142,25 @@ const startServer = ({ argv, app }) => {
199
142
  const raw = req.query.pattern || '*'
200
143
  const patterns = raw.split(',').map(p => p.trim()).filter(Boolean)
201
144
  const limit = parseInt(req.query.limit) || null
202
- let results = findIndexedDomains(farmRoot, patterns)
145
+ let results = listDomains(farms, patterns, restricted, 'status/semantic-vectors.json')
146
+ .map(({ farm, domain }) => {
147
+ let pageCount = null
148
+ try {
149
+ const pages = JSON.parse(fs.readFileSync(
150
+ path.join(farm, domain, 'status', 'semantic-vectors.json'), 'utf8'))
151
+ pageCount = Array.isArray(pages) ? pages.length : null
152
+ } catch { /* ignore */ }
153
+ return { domain, page_count: pageCount }
154
+ })
203
155
  if (limit) results = results.slice(0, limit)
204
156
  res.json(results)
205
157
  })
206
158
 
207
- // ── GET /system/semantic-vectors.json ─────────────────────────────────────
208
- // Two modes:
209
- // ?domain=david.hitchhikers.earth — local proxy: searches all farm roots
210
- // on disk (private farm + WIKI_EXTRA_FARMS). Lets localhost clients load
211
- // vectors for any domain without CORS or remote plugin requirements.
212
- // (no ?domain) — req.hostname selects the domain; works for farm requests
213
- // where Caddy routes each hostname to this server.
159
+ // ── GET /system/semantic-vectors.json[?domain=] ────────────────────────────
214
160
  app.get('/system/semantic-vectors.json', (req, res) => {
215
161
  cors(res)
216
162
  const domain = req.query.domain || req.hostname || 'localhost'
217
- const vecFile = findInFarms(farmRoot, domain, 'status/semantic-vectors.json')
163
+ const vecFile = findInFarms(farms, domain, 'status/semantic-vectors.json')
218
164
  if (!vecFile) {
219
165
  return res.status(404).json({ error: `vectors not found for ${domain}` })
220
166
  }
@@ -226,17 +172,72 @@ const startServer = ({ argv, app }) => {
226
172
  })
227
173
  })
228
174
 
229
- // ── GET /system/embed.json?text=… ────────────────────────────────────────
175
+ // ── GET /system/embed.json?text=… ──────────────────────────────────────────
230
176
  app.get('/system/embed.json', async (req, res) => {
231
177
  cors(res)
232
178
  const text = req.query.text
233
179
  if (!text) return res.status(400).json({ error: 'text parameter required' })
234
180
  try {
235
- const result = await postJson(EMBED_URL, { text })
236
- res.json(result)
181
+ res.json({ vector: await embedText(text) })
182
+ } catch (e) {
183
+ console.error('[wiki-plugin-similarity] embed error:', e.message)
184
+ res.status(502).json({ error: `embedding unavailable: ${e.message}` })
185
+ }
186
+ })
187
+
188
+ // ── POST /system/search-report.json ────────────────────────────────────────
189
+ app.post('/system/search-report.json', async (req, res) => {
190
+ cors(res)
191
+ try {
192
+ const body = await readBody(req)
193
+ if (!body.query) return res.status(400).json({ error: 'query required' })
194
+ const page = await buildReport(
195
+ body.query, body.domains || ['*'], body.limit || 10, ctx,
196
+ body.threshold ?? null, !!body.live)
197
+ res.json(page)
198
+ } catch (e) {
199
+ console.error('[wiki-plugin-similarity] search-report error:', e.message)
200
+ res.status(500).json({ error: `search-report failed: ${e.message}` })
201
+ }
202
+ })
203
+
204
+ // ── GET /system/farm-search.json?q=…&pattern=…&limit=… ────────────────────
205
+ app.get('/system/farm-search.json', (req, res) => {
206
+ cors(res)
207
+ const q = (req.query.q || '').trim()
208
+ if (!q) return res.status(400).json({ error: 'q parameter required' })
209
+ const patterns = (req.query.pattern || '*').split(',').map(p => p.trim()).filter(Boolean)
210
+ const limit = parseInt(req.query.limit) || 10
211
+ try {
212
+ const outcome = searchFarm(farms, patterns, restricted, q, limit)
213
+ if (req.query.format === 'flat') return res.json(outcome)
214
+ res.json(keywordReportPage(q, outcome, limit, patterns))
215
+ } catch (e) {
216
+ console.error('[wiki-plugin-similarity] farm-search error:', e.message)
217
+ res.status(500).json({ error: `farm-search failed: ${e.message}` })
218
+ }
219
+ })
220
+
221
+ // ── GET /system/build-index.json?domains=…&force=… ────────────────────────
222
+ // Heavy embedding is the farm indexer's job — proxy when configured, else
223
+ // explain where indexing happens.
224
+ app.get('/system/build-index.json', async (req, res) => {
225
+ cors(res)
226
+ if (!INDEXER_URL) {
227
+ return res.status(501).json({
228
+ error: 'no farm indexer configured on this server',
229
+ hint: 'Indexing runs on the farm indexer (Pi5 on the Hitchhikers farm); ' +
230
+ 'indexes arrive by sync. Set WIKI_INDEXER_URL to enable proxying.',
231
+ })
232
+ }
233
+ try {
234
+ const qs = new URLSearchParams({
235
+ domains: req.query.domains || '*',
236
+ force: req.query.force || '0',
237
+ })
238
+ res.json(await getJson(`${INDEXER_URL}?${qs}`))
237
239
  } catch (e) {
238
- console.error('[wiki-plugin-similarity] embed proxy error:', e.message)
239
- res.status(502).json({ error: `embed service unavailable: ${e.message}` })
240
+ res.status(502).json({ error: `farm indexer unavailable: ${e.message}` })
240
241
  }
241
242
  })
242
243