wiki-plugin-similarity 0.4.3 → 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.
- package/client/similarity.js +35 -35
- package/client/similarity.js.map +3 -3
- package/package.json +9 -1
- package/pages/about-similarity-plugin +34 -4
- package/server/embedder.js +48 -0
- package/server/farm-lib.js +102 -0
- package/server/farm-search.js +122 -0
- package/server/package.json +3 -0
- package/server/search-report.js +0 -0
- package/server/server.js +163 -155
package/client/similarity.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
/* wiki-plugin-similarity - 0.
|
|
2
|
-
(()=>{var
|
|
3
|
-
`)){let
|
|
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,
|
|
28
|
-
title="${t} \u2014 score ${
|
|
29
|
-
data-site="${t}">`,
|
|
30
|
-
<style>${
|
|
31
|
-
<div class="similarity" data-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(
|
|
37
|
-
<style>${
|
|
38
|
-
<div class="similarity" data-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 ${
|
|
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(
|
|
44
|
-
<style>${
|
|
45
|
-
<div class="similarity" data-id="${
|
|
46
|
-
<div class="sim-status">Loading indexed domains (${
|
|
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(
|
|
49
|
-
<style>${
|
|
50
|
-
<div class="similarity" data-id="${
|
|
51
|
-
<div class="sim-status">Finding similar pages across ${
|
|
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(
|
|
54
|
-
<style>${
|
|
55
|
-
<div class="similarity" data-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: ${
|
|
60
|
+
<div class="sim-status">Domains: ${h}</div>
|
|
61
61
|
<div class="sim-results"></div>
|
|
62
|
-
</div>`)}else{let
|
|
63
|
-
<style>${
|
|
64
|
-
<div class="similarity" data-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: ${
|
|
69
|
+
<div class="sim-status">Domains: ${h}</div>
|
|
70
70
|
<div class="sim-results"></div>
|
|
71
|
-
</div>`)}},at=(t,
|
|
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
|
-
${
|
|
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>${
|
|
78
|
+
<td>${p!=null?p.toLocaleString():"\u2014"}</td>
|
|
79
79
|
</tr>`).join("")}
|
|
80
80
|
</table>
|
|
81
|
-
<p class="sim-count">${
|
|
82
|
-
`);L=await H(
|
|
83
|
-
`),D=[{type:"markdown",id:
|
|
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
|
-
${
|
|
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
|
package/client/similarity.js.map
CHANGED
|
@@ -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", "
|
|
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": ";MAkCA,IAAMA,EAAqB,CAAE,KAAM,IAAM,OAAQ,IAAM,IAAK,GAAK,EAC3DC,EAAqBD,EAAmB,OACxCE,EAAqB,GAWrBC,EAAWC,GAAQ,CACvB,IAAMC,EAAU,CAAC,EACbC,EAAY,KACZC,EAAY,KACZC,EAAY,SACZC,EAAY,GACZC,EAAY,GACZC,EAAY,KACZC,EAAY,KAKVC,EAAS,CAACC,EAAOC,IAAOD,IAAUC,GAAOD,EAAM,WAAWC,CAAE,GAAK,SAAS,KAAKD,EAAM,MAAMC,EAAG,MAAM,CAAC,EAGrGC,EAAS,CAACC,EAAMF,IAAOE,EAAK,MAAMF,EAAG,MAAM,EAAE,QAAQ,YAAa,EAAE,EAAE,KAAK,EAEjF,QAAWG,KAAOd,EAAK,MAAM;AAAA,CAAI,EAAG,CAClC,IAAMa,EAAOC,EAAI,KAAK,EACtB,GAAI,CAACD,GAAQA,EAAK,WAAW,GAAG,EAAG,SAEnC,IAAMH,EAAQG,EAAK,YAAY,EAC/B,GAAIJ,EAAMC,EAAO,MAAM,EAAI,CAAEL,EAAO,GAAM,QAAS,CACnD,GAAII,EAAMC,EAAO,QAAQ,EAAG,CACtB,CAACT,EAAM,QAAUG,IAAS,WAAUA,EAAO,UAC/C,QACF,CACA,GAAIK,EAAMC,EAAO,QAAQ,EAAG,CACtBN,IAAS,WAAUA,EAAO,UAC9B,QACF,CACA,GAAIK,EAAMC,EAAO,SAAS,EAAG,CACvBN,IAAS,WAAUA,EAAO,WAC9B,QACF,CACA,GAAIK,EAAMC,EAAO,OAAO,EAAG,CACrBN,IAAS,WAAUA,EAAO,SAC9B,QACF,CACA,GAAIK,EAAMC,EAAO,OAAO,EAAG,CAAEJ,EAAQ,GAAM,QAAS,CACpD,GAAIG,EAAMC,EAAO,OAAO,EAAG,CACzBH,EAAWK,EAAIC,EAAM,OAAO,EACxBT,IAAS,WAAUA,EAAO,SAC9B,QACF,CACA,GAAIK,EAAMC,EAAO,QAAQ,EAAG,CAAEF,EAAQI,EAAIC,EAAM,QAAQ,EAAG,QAAS,CACpE,GAAIJ,EAAMC,EAAO,MAAM,EAAG,CACpB,CAACT,EAAM,QAAUG,IAAS,WAAUA,EAAO,QAC/C,QACF,CACA,GAAIK,EAAMC,EAAO,SAAS,EAAG,CAC3B,IAAMK,EAAQH,EAAIF,EAAO,SAAS,EAAE,YAAY,EAChDR,EAAYN,EAAmBmB,CAAK,GAAKlB,EACrC,CAACI,EAAM,QAAUG,IAAS,WAAUA,EAAO,WAC/C,QACF,CACA,GAAIK,EAAMC,EAAO,WAAW,EAAG,CAC7B,IAAMM,EAAKJ,EAAIC,EAAM,WAAW,EAChCX,EAAYN,EAAmBoB,EAAG,YAAY,CAAC,IAAM,WAAWA,CAAE,GAAKnB,GACvE,QACF,CACA,GAAIY,EAAMC,EAAO,OAAO,EAAG,CACzBP,EAAQ,SAASS,EAAIC,EAAM,OAAO,CAAC,GAAKf,EACxC,QACF,CAEAG,EAAM,KAAK,CAAC,SAAU,QAAS,SAAS,EAAE,SAASS,CAAK,EAAIA,EAAQG,CAAI,CAC1E,CAEA,MAAO,CACL,KAAAT,EACA,MAAAH,EACA,UAAWC,GAAaL,EACxB,MAAWM,GAAaL,EACxB,KAAAO,EACA,MAAAC,EACA,SAAAC,EACA,MAAAC,EACA,aAAcN,IAAc,IAC9B,CACF,EAEMe,EAASC,GAAQA,EAAK,SAAS,GAAG,GAAKA,EAAK,SAAS,GAAG,EAIxDC,EAAUD,GAAQA,IAAS,UAAYA,IAAS,SAAWA,IAAS,UAIpEE,EAAUC,GAASA,EAAM,YAAY,EAAE,QAAQ,OAAQ,GAAG,EAAE,QAAQ,cAAe,EAAE,EAIrFC,EAAc,IAAI,IAElBC,EAAwB,MAAOL,EAAMM,IAAW,CACpD,GAAIF,EAAY,IAAIJ,CAAI,EAAG,OAAOI,EAAY,IAAIJ,CAAI,EACtD,IAAMO,EAAM,GAAGD,CAAM,wCAAwC,mBAAmBN,CAAI,CAAC,GAC/EQ,EAAM,MAAM,MAAMD,CAAG,EAC3B,GAAI,CAACC,EAAI,GAAI,MAAM,IAAI,MAAM,2BAA2BA,EAAI,MAAM,EAAE,EACpE,IAAMC,EAAO,MAAMD,EAAI,KAAK,EAC5B,OAAAJ,EAAY,IAAIJ,EAAMS,CAAI,EACnBA,CACT,EAEMC,EAAiB,MAAO3B,EAAOuB,IAAW,CACzCvB,EAAM,SAAQA,EAAQ,CAAC,OAAO,SAAS,QAAQ,GACpD,IAAM4B,EAAO,IAAI,IACXC,EAAS,CAAC,EAChB,QAAWZ,KAAQjB,EACjB,GAAIiB,IAAS,KAAOD,EAAOC,CAAI,GAAKC,EAAQD,CAAI,EAC9C,QAAWa,KAAQ,MAAMR,EAAsBL,EAAMM,CAAM,EACpDK,EAAK,IAAIE,EAAK,MAAM,IAAKF,EAAK,IAAIE,EAAK,MAAM,EAAGD,EAAO,KAAKC,CAAI,QAE7DF,EAAK,IAAIX,CAAI,IACvBW,EAAK,IAAIX,CAAI,EAAGY,EAAO,KAAK,CAAE,OAAQZ,EAAM,WAAY,IAAK,CAAC,GAGlE,OAAOY,CACT,EAIME,EAAc,IAAI,IAKlBC,GAAYC,GAChB,GAAG,OAAO,SAAS,MAAM,wCAAwC,mBAAmBA,CAAM,CAAC,GAEvFC,EAAc,MAAMD,GAAU,CAClC,GAAIF,EAAY,IAAIE,CAAM,EAAG,OAAOF,EAAY,IAAIE,CAAM,EAC1D,IAAMR,EAAM,MAAM,MAAMO,GAAUC,CAAM,CAAC,EACzC,GAAI,CAACR,EAAI,GAAI,MAAO,CAAC,EACrB,IAAMU,EAAO,MAAMV,EAAI,KAAK,EAC5B,OAAAM,EAAY,IAAIE,EAAQE,CAAI,EACrBA,CACT,EAIMC,EAAe,MAAOrC,EAAMwB,IAAW,CAC3C,IAAME,EAAM,MAAM,MAAM,GAAGF,CAAM,2BAA2B,mBAAmBxB,CAAI,CAAC,EAAE,EACtF,GAAI,CAAC0B,EAAI,GAAI,MAAM,IAAI,MAAM,iBAAiBA,EAAI,MAAM,EAAE,EAC1D,OAAQ,MAAMA,EAAI,KAAK,GAAG,MAC5B,EAIMY,GAAmB,MAAOC,EAAML,IAAW,CAE/C,IAAMM,GADQ,MAAML,EAAYD,CAAM,GAClB,KAAKO,GAAKA,EAAE,OAASF,CAAI,EAC7C,OAAOC,EAAQA,EAAM,OAAS,IAChC,EAIME,EAAa,CAACC,EAAUC,EAAe,CAAE,UAAA1C,EAAW,MAAAC,EAAO,YAAA0C,EAAa,cAAAC,CAAc,IAAM,CAChG,IAAMC,EAAU,CAAC,EACjB,OAAW,CAAE,OAAAb,EAAQ,MAAAc,CAAM,IAAKJ,EAC9B,OAAW,CAAE,KAAAL,EAAM,MAAAlB,EAAO,OAAA4B,CAAO,IAAKD,EAAO,CAC3C,GAAIT,IAASM,GAAeX,IAAWY,EAAe,SACtD,IAAII,EAAM,EACV,QAASC,EAAI,EAAGA,EAAIR,EAAS,OAAQQ,IAAKD,GAAOP,EAASQ,CAAC,EAAIF,EAAOE,CAAC,EACnED,GAAOhD,GAAW6C,EAAQ,KAAK,CAAE,OAAAb,EAAQ,KAAAK,EAAM,MAAAlB,EAAO,MAAO6B,CAAI,CAAC,CACxE,CAEF,OAAAH,EAAQ,KAAK,CAACK,EAAG,IAAM,EAAE,MAAQA,EAAE,KAAK,EACjCL,EAAQ,MAAM,EAAG5C,CAAK,CAC/B,EAIMkD,EAAoB,MAAOpD,EAAOuB,IAAW,CACjD,IAAM8B,EAAU,MAAM1B,EAAe3B,EAAOuB,CAAM,EAIlD,OAHgB,MAAM,QAAQ,IAC5B8B,EAAQ,IAAI,MAAO,CAAE,OAAApB,CAAO,KAAO,CAAE,OAAAA,EAAQ,MAAO,MAAMC,EAAYD,CAAM,CAAE,EAAE,CAClF,GACe,OAAOqB,GAAKA,EAAE,MAAM,OAAS,CAAC,CAC/C,EAMMC,EAAWC,GAAM,aAAaA,CAAE,GAEhCC,GAAY3B,GAAQ,CACxB,GAAI,CACF,IAAM4B,EAAI,KAAK,MAAM,aAAa,QAAQH,EAASzB,EAAK,EAAE,CAAC,GAAK,MAAM,EACtE,OAAO4B,GAAG,QAAU5B,EAAK,MAAQ,IAAM4B,EAAI,IAC7C,MAAQ,CAAE,OAAO,IAAK,CACxB,EAEMC,EAAa,CAAC7B,EAAMK,IAAS,CACjC,GAAI,CACF,aAAa,QAAQoB,EAASzB,EAAK,EAAE,EAAG,KAAK,UAAU,CACrD,KAAMA,EAAK,MAAQ,GACnB,GAAM,KAAK,IAAI,EACf,GAAGK,CACL,CAAC,CAAC,CACJ,MAAQ,CAAoC,CAC9C,EAEMyB,GAAWC,GAAM,CACrB,IAAMC,EAAI,KAAK,OAAO,KAAK,IAAI,EAAID,GAAM,GAAI,EAC7C,OAAIC,EAAI,GAAc,GAAGA,CAAC,QACtBA,EAAI,KAAc,GAAG,KAAK,MAAMA,EAAI,EAAE,CAAC,QACvCA,EAAI,MAAc,GAAG,KAAK,MAAMA,EAAI,IAAI,CAAC,QACtC,GAAG,KAAK,MAAMA,EAAI,KAAK,CAAC,OACjC,EAIMC,EAAS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EA0BTC,GAAW,CAAC/B,EAAQgC,IACxB,qCAAqC,OAAO,KAAK,KAAKhC,CAAM,EAAE,KAAK,CAAC;AAAA,iBACrDA,CAAM,iBAAYgC,EAAM,QAAQ,CAAC,CAAC;AAAA,qBAC9BhC,CAAM,KAIrBiC,EAAU,CAACjC,EAAQK,EAAMlB,EAAO6C,IACpC,mCAAmC7C,CAAK,gBAAgBkB,CAAI,gBAAgBL,CAAM,cAC/E+B,GAAS/B,EAAQgC,CAAK,CAAC,IAAI7C,CAAK,OAExB+C,GAAO,CAACC,EAAKtC,IAAS,CACjC,GAAM,CAAE,KAAA3B,EAAM,MAAAH,EAAO,UAAAC,EAAW,MAAAC,EAAO,MAAAG,EAAO,SAAAC,EAAU,MAAAC,CAAM,EAAIT,EAASgC,GAAM,MAAQ,EAAE,EAC3F,GAAI3B,IAAS,QACXiE,EAAI,KAAK;AAAA,eACEL,CAAM;AAAA,yCACoBjC,EAAK,EAAE;AAAA;AAAA,oCAEZvB,GAAS,MAAM;AAAA;AAAA;AAAA,aAGtC,UACAJ,IAAS,QAClBiE,EAAI,KAAK;AAAA,eACEL,CAAM;AAAA,yCACoBjC,EAAK,EAAE;AAAA;AAAA,oCAEZvB,GAAS,SAASP,EAAM,OAASA,EAAM,KAAK,IAAI,EAAI,GAAG,GAAGK,EAAQ,WAAa,EAAE,EAAE;AAAA;AAAA;AAAA,aAG1G,UACAF,IAAS,OAAQ,CAC1B,IAAMI,EAAQP,EAAM,OAASA,EAAM,KAAK,IAAI,EAAI,IAChDoE,EAAI,KAAK;AAAA,eACEL,CAAM;AAAA,yCACoBjC,EAAK,EAAE;AAAA,2DACWvB,CAAK;AAAA;AAAA,aAEnD,CACX,SAAWJ,IAAS,UAAW,CAC7B,IAAMI,EAAQP,EAAM,OAASA,EAAM,KAAK,IAAI,EAAI,iBAChDoE,EAAI,KAAK;AAAA,eACEL,CAAM;AAAA,yCACoBjC,EAAK,EAAE;AAAA,+DACevB,CAAK;AAAA;AAAA,aAEvD,CACX,SAAWJ,IAAS,UAAYA,IAAS,UAAYA,IAAS,UAAW,CACvE,IAAMI,EAAQP,EAAM,OAASA,EAAM,KAAK,IAAI,EAAI,mBAC1CqE,EAAWlE,IAAS,SAAW,SAAWA,IAAS,UAAY,UAAY,SACjFiE,EAAI,KAAK;AAAA,eACEL,CAAM;AAAA,yCACoBjC,EAAK,EAAE;AAAA;AAAA;AAAA,oCAGZuC,CAAQ;AAAA;AAAA,2CAED9D,CAAK;AAAA;AAAA,aAEnC,CACX,KAAO,CACL,IAAMA,EAAQP,EAAM,OAASA,EAAM,KAAK,IAAI,EAAI,mBAChDoE,EAAI,KAAK;AAAA,eACEL,CAAM;AAAA,yCACoBjC,EAAK,EAAE;AAAA;AAAA;AAAA;AAAA;AAAA,2CAKLvB,CAAK;AAAA;AAAA,aAEnC,CACX,CACF,EAEa+D,GAAO,CAACF,EAAKtC,IAAS,CACjC,GAAM,CAAE,KAAA3B,EAAM,MAAAH,EAAO,UAAAC,EAAW,MAAAC,EAAO,KAAAE,EAAM,MAAAC,EAAO,SAAAC,EAAU,aAAAiE,CAAa,EACzEzE,EAASgC,GAAM,MAAQ,EAAE,EACrBP,EAAU,OAAO,SAAS,OAC1BiD,EAAUJ,EAAI,KAAK,aAAa,EAAE,CAAC,EACnCK,EAAUrE,EAAO,KAAOqD,GAAU3B,CAAI,EAItC4C,EAAgB,CAACC,EAAM5B,EAAO6B,IAAa,CAC/C,IAAMC,EAAQ,CAAC,GAAGF,CAAI,WAAM5B,EAAM,eAAe,CAAC,iBAAiB6B,CAAQ,UAAU,EACrF,OAAIL,GAAcM,EAAM,KAAK,aAAa5E,CAAS,EAAE,EACrD4E,EAAM,KAAK,SAAS3E,CAAK,EAAE,EACvBE,GAAMyE,EAAM,KAAK,MAAM,EACpBA,EAAM,KAAK,QAAK,CACzB,EAEAT,EAAI,GAAG,WAAYd,GAAK,CAClB,EAAEA,EAAE,MAAM,EAAE,QAAQ,YAAY,EAAE,QACtC,OAAO,KAAK,WAAWc,EAAKtC,CAAI,CAClC,CAAC,EACDsC,EAAI,GAAG,QAAS,YAAa,SAAUd,EAAG,CACxCA,EAAE,eAAe,EACjB,IAAMwB,EAAK,EAAE,IAAI,EAEXC,EAAQzB,EAAE,SAAW,KAAOc,EAAI,QAAQ,OAAO,EACrD,OAAO,KAAK,eAAeU,EAAG,KAAK,OAAO,EAAGC,EAAOD,EAAG,KAAK,MAAM,CAAC,CACrE,CAAC,EAED,IAAME,EAAa,CAAChF,EAAM,QAAWA,EAAM,SAAW,GAAKA,EAAM,CAAC,IAAM,IACpE,UACAA,EAAM,SAAW,EAAI,MAAMA,EAAM,CAAC,CAAC,GAAK,aAEtCiF,EAAYpB,GAAMA,EAAK,gBAAaD,GAASC,CAAE,CAAC,GAAK,GAE3D,GAAI1D,IAAS,OAAQ,CACnB,IAAM+E,EAAUd,EAAI,KAAK,WAAW,EAAE,CAAC,EACjCe,EAAWnF,EAAM,OAASA,EAAM,KAAK,GAAG,EAAI,IAE5CoF,EAAa,CAAC/B,EAASQ,IAAO,CAClC,IAAMwB,EAAahC,EAAQ,OAAO,CAACiC,EAAGC,IAAMD,GAAKC,EAAE,YAAc,GAAI,CAAC,EACtEf,EAAO,MAAM,QAAU,OACvBU,EAAQ,UAAY;AAAA;AAAA;AAAA,YAGd7B,EAAQ,IAAI,CAAC,CAAE,OAAApB,EAAQ,WAAAuD,CAAW,IAAM;AAAA;AAAA,sDAEE,OAAO,KAAK,KAAKvD,CAAM,EAAE,KAAK,CAAC;AAAA,gCACrDA,CAAM,gBAAgBA,CAAM,MAAMA,CAAM;AAAA,oBACpDuD,GAAc,KAAOA,EAAW,eAAe,EAAI,QAAG;AAAA,kBACxD,EAAE,KAAK,EAAE,CAAC;AAAA;AAAA,+BAEGnC,EAAQ,MAAM,mBAAcgC,EAAW,eAAe,CAAC,SAASJ,EAAUpB,CAAE,CAAC,MACxG,EAEIY,GAAO,QACTW,EAAWX,EAAM,QAASA,EAAM,EAAE,GAEhC,SAAY,CACZ,GAAI,CACF,IAAMjD,EAAM,GAAGD,CAAM,wCAAwC,mBAAmB4D,CAAQ,CAAC,UAAUjF,CAAK,GAClGuB,EAAM,MAAM,MAAMD,CAAG,EAC3B,GAAI,CAACC,EAAI,GAAI,MAAM,IAAI,MAAM,2BAA2BA,EAAI,MAAM,EAAE,EACpE,IAAM4B,EAAU,MAAM5B,EAAI,KAAK,EAC/B,GAAI,CAAC4B,EAAQ,OAAQ,CAAEmB,EAAO,YAAc,2BAA4B,MAAO,CAC/EY,EAAW/B,EAAS,IAAI,EACxBM,EAAW7B,EAAM,CAAE,QAAAuB,CAAQ,CAAC,CAC9B,OAASC,EAAG,CACVkB,EAAO,YAAc,UAAUlB,EAAE,OAAO,EAC1C,CACF,GAAG,CAGP,SAAWnD,IAAS,UAAW,CAC7B,IAAM2C,EAAUsB,EAAI,KAAK,cAAc,EAAE,CAAC,EAEpCqB,EAAe,CAACC,EAAQ7B,IAAO,CACnC,GAAI,CAAC6B,EAAO,OAAQ,CAClBlB,EAAO,YAAc,0CAA0CvE,CAAS,GACxE,MACF,CACA6C,EAAQ,UAAY,6BAClB4C,EAAO,IAAI,CAAC,CAAE,OAAAzD,EAAQ,KAAAK,EAAM,MAAAlB,EAAO,MAAA6C,CAAM,IACvC,OAAOC,EAAQjC,EAAQK,EAAMlB,EAAO6C,CAAK,CAAC,OAAO,EAAE,KAAK,EAAE,CAC9D,6BAA6ByB,EAAO,MAAM,UAAUV,CAAU,GAAGC,EAAUpB,CAAE,CAAC,OAC9EW,EAAO,MAAM,QAAU,MACzB,EAEIC,GAAO,OACTgB,EAAahB,EAAM,OAAQA,EAAM,EAAE,GAEjC,SAAY,CACZ,GAAI,CACF,IAAMM,EAAgBX,EAAI,QAAQ,OAAO,EACnCuB,EAAgBZ,EAAM,KAAK,QAAQ,EAAE,KAAK,EAAE,KAAK,GAAK,SAAS,MAC/Da,EAAgBzE,EAAQwE,CAAS,EACjCE,EAAgB,OAAO,SAAS,SAEhClD,EAAgB,MAAMS,EAAkBpD,EAAOuB,CAAM,EACrDuE,EAAQnD,EAAc,OAAO,CAAC2C,EAAGhC,IAAMgC,EAAIhC,EAAE,MAAM,OAAQ,CAAC,EAClEkB,EAAO,YAAc,aAAasB,EAAM,eAAe,CAAC,eAExD,IAAIC,EAAO,MAAM1D,GAAiBuD,EAAaC,CAAa,EAC5D,GAAI,CAACE,EAAM,CACTvB,EAAO,YAAc,yCACrB,IAAMwB,EAAWjB,EAAM,KAAK,OAAO,EAAE,IAAI,CAACkB,EAAGC,IAAO,EAAEA,CAAE,EAAE,KAAK,EAAE,KAAK,CAAC,EAAE,IAAI,EAAE,OAAO,OAAO,EAAE,KAAK;AAAA,CAAI,EACxGH,EAAO,MAAM3D,EAAa4D,GAAYL,EAAWpE,CAAM,CACzD,CAEA,IAAMmE,EAASjD,EAAWsD,EAAMpD,EAAe,CAC7C,UAAA1C,EAAW,MAAAC,EAAO,YAAa0F,EAAa,cAAeC,CAC7D,CAAC,EACDJ,EAAaC,EAAQ,IAAI,EACrBA,EAAO,QAAQ/B,EAAW7B,EAAM,CAAE,OAAA4D,CAAO,CAAC,CAChD,OAASpC,EAAG,CACVkB,EAAO,YAAc,UAAUlB,EAAE,OAAO,EAC1C,CACF,GAAG,CAGP,SAAWnD,IAAS,QAAS,CAE3B,IAAMgG,EAAM/B,EAAI,KAAK,UAAU,EAAE,CAAC,EAE5BgC,EAAU,SAAY,CAC1B,GAAI,CAAC9F,EAAU,CAAEkE,EAAO,YAAc,4CAAwC,MAAO,CACrF2B,EAAI,SAAW,GACf3B,EAAO,YAAc,iBACrB,GAAI,CACF,IAAM/C,EAAM,MAAM,MAAMnB,CAAQ,EAChC,GAAI,CAACmB,EAAI,GAAI,MAAM,IAAI,MAAM,iBAAiBA,EAAI,MAAM,EAAE,EAC1D,IAAM4E,EAAO,MAAM5E,EAAI,KAAK,EAC5B,OAAO,KAAK,WAAW,OAAO,KAAK,QAAQ4E,CAAI,EAAG,CAAE,MAAOjC,EAAI,QAAQ,OAAO,CAAE,CAAC,EACjFI,EAAO,YAAc,EACvB,OAASlB,EAAG,CACVkB,EAAO,YAAc,UAAUlB,EAAE,OAAO,EAC1C,QAAE,CACA6C,EAAI,SAAW,EACjB,CACF,EAEAA,EAAI,iBAAiB,QAASC,CAAO,CAEvC,SAAWjG,IAAS,QAAS,CAE3B,IAAMgG,EAAM/B,EAAI,KAAK,UAAU,EAAE,CAAC,EAE5BkC,EAAU,SAAY,CAC1BH,EAAI,SAAW,GACf3B,EAAO,YAAc,2DACrB,GAAI,CACF,IAAMnB,EAAU,oBAAoBrD,EAAM,OAASA,EAAQ,CAAC,GAAG,GAAG,KAAK,GAAG,CAAC,EACrEyB,EAAM,MAAM,MAChB,GAAGF,CAAM,oCAAoC8B,CAAO,UAAUhD,EAAQ,EAAI,CAAC,EAAE,EAC/E,GAAIoB,EAAI,SAAW,IAAK,CACtB,IAAM8E,EAAO,MAAM9E,EAAI,KAAK,EAAE,MAAM,KAAO,CAAC,EAAE,EAC9C,MAAM,IAAI,MAAM8E,EAAK,MAAQ,oDAAoD,CACnF,CACA,GAAI,CAAC9E,EAAI,GAAI,MAAM,IAAI,MAAM,uBAAuBA,EAAI,MAAM,EAAE,EAChE,IAAM4E,EAAO,MAAM5E,EAAI,KAAK,EAC5B,OAAO,KAAK,WAAW,OAAO,KAAK,QAAQ4E,CAAI,EAAG,CAAE,MAAOjC,EAAI,QAAQ,OAAO,CAAE,CAAC,EACjFI,EAAO,YAAc,EACvB,OAASlB,EAAG,CACVkB,EAAO,YAAc,UAAUlB,EAAE,OAAO,EAC1C,QAAE,CACA6C,EAAI,SAAW,EACjB,CACF,EAEAA,EAAI,iBAAiB,QAASG,CAAO,CAEvC,SAAWnG,IAAS,SAAU,CAE5B,IAAMqG,EAAQpC,EAAI,KAAK,YAAY,EAAE,CAAC,EAChC+B,EAAQ/B,EAAI,KAAK,UAAU,EAAE,CAAC,EAChCqC,EAAY,YAAYzG,EAAM,OAASA,EAAM,KAAK,IAAI,EAAI,GAAG,IAI/D,SAAY,CACZ,GAAI,CACF,IAAMqD,EAAU,MAAM1B,EAAe3B,EAAM,OAASA,EAAQ,CAAC,GAAG,EAAGuB,CAAM,EACnEwB,EAAQM,EAAQ,OAAO,CAACiC,EAAGC,IAAMD,GAAKC,EAAE,YAAc,GAAI,CAAC,EACjEkB,EAAY/B,EAAc,eAAgB3B,EAAOM,EAAQ,MAAM,EAC/DmB,EAAO,YAAciC,CACvB,OAASnD,EAAG,CACVkB,EAAO,YAAc,+BAA+BlB,EAAE,OAAO,EAC/D,CACF,GAAG,EAEH,IAAMoD,EAAW,SAAY,CAC3B,IAAMC,EAAQH,EAAM,MAAM,KAAK,EAC/B,GAAKG,EACL,CAAAR,EAAI,SAAW,GACf3B,EAAO,YAAc,0BACrB,GAAI,CACF,IAAM+B,EAAO,CAAE,MAAAI,EAAO,QAAS3G,EAAM,OAASA,EAAQ,CAAC,GAAG,EAAG,MAAAE,EAAO,KAAAE,CAAK,EACrEmE,IAAcgC,EAAK,UAAYtG,GACnC,IAAMwB,EAAM,MAAM,MAAM,GAAGF,CAAM,6BAA8B,CAC7D,OAAQ,OACR,QAAS,CAAE,eAAgB,kBAAmB,EAC9C,KAAM,KAAK,UAAUgF,CAAI,CAC3B,CAAC,EACD,GAAI,CAAC9E,EAAI,GAAI,MAAM,IAAI,MAAM,yBAAyBA,EAAI,MAAM,EAAE,EAClE,IAAM4E,EAAO,MAAM5E,EAAI,KAAK,EACtBmF,EAAU,OAAO,KAAK,QAAQP,CAAI,EACxC,OAAO,KAAK,WAAWO,EAAS,CAAE,MAAOxC,EAAI,QAAQ,OAAO,CAAE,CAAC,EAC/DI,EAAO,YAAciC,CACvB,OAASnD,EAAG,CACVkB,EAAO,YAAc,UAAUlB,EAAE,OAAO,EAC1C,QAAE,CACA6C,EAAI,SAAW,EACjB,EACF,EAEAA,EAAI,iBAAiB,QAASO,CAAQ,EACtCF,EAAM,iBAAiB,UAAWlD,GAAK,CAAMA,EAAE,MAAQ,SAASoD,EAAS,CAAE,CAAC,CAE9E,SAAWvG,IAAS,UAAW,CAG7B,IAAMqG,EAAQpC,EAAI,KAAK,YAAY,EAAE,CAAC,EAChC+B,EAAQ/B,EAAI,KAAK,UAAU,EAAE,CAAC,EAEpCI,EAAO,YAAc,wCAAmCxE,EAAM,OAASA,EAAM,KAAK,IAAI,EAAI,GAAG,eAAYE,CAAK,GAE9G,IAAM2G,EAAY,SAAY,CAC5B,IAAMF,EAAQH,EAAM,MAAM,KAAK,EAC/B,GAAKG,EACL,CAAAR,EAAI,SAAW,GACf3B,EAAO,YAAc,oCACrB,GAAI,CACF,IAAMsC,EAAU,oBAAoB9G,EAAM,OAASA,EAAQ,CAAC,GAAG,GAAG,KAAK,GAAG,CAAC,EACrEyB,EAAM,MAAM,MAChB,GAAGF,CAAM,8BAA8B,mBAAmBoF,CAAK,CAAC,YAAYG,CAAO,UAAU5G,CAAK,EAAE,EACtG,GAAI,CAACuB,EAAI,GAAI,MAAM,IAAI,MAAM,uBAAuBA,EAAI,MAAM,EAAE,EAChE,IAAM4E,EAAO,MAAM5E,EAAI,KAAK,EAC5B,OAAO,KAAK,WAAW,OAAO,KAAK,QAAQ4E,CAAI,EAAG,CAAE,MAAOjC,EAAI,QAAQ,OAAO,CAAE,CAAC,EACjFI,EAAO,YAAc,wCAAmCxE,EAAM,OAASA,EAAM,KAAK,IAAI,EAAI,GAAG,eAAYE,CAAK,EAChH,OAASoD,EAAG,CACVkB,EAAO,YAAc,UAAUlB,EAAE,OAAO,EAC1C,QAAE,CACA6C,EAAI,SAAW,EACjB,EACF,EAEAA,EAAI,iBAAiB,QAASU,CAAS,EACvCL,EAAM,iBAAiB,UAAWlD,GAAK,CAAMA,EAAE,MAAQ,SAASuD,EAAU,CAAE,CAAC,CAE/E,SAAW1G,IAAS,SAAU,CAE5B,IAAMqG,EAAUpC,EAAI,KAAK,YAAY,EAAE,CAAC,EAClC+B,EAAU/B,EAAI,KAAK,UAAU,EAAE,CAAC,EAChCtB,EAAUsB,EAAI,KAAK,cAAc,EAAE,CAAC,EACtCzB,EAAgB,MAElB,SAAY,CACZ,GAAI,CACG8B,IAAOD,EAAO,YAAc,2BACjC7B,EAAgB,MAAMS,EAAkBpD,EAAOuB,CAAM,EACrD,IAAMuE,EAAQnD,EAAc,OAAO,CAAC2C,EAAGhC,IAAMgC,EAAIhC,EAAE,MAAM,OAAQ,CAAC,EAClEkB,EAAO,YAAcE,EAAc,eAAgBoB,EAAOnD,EAAc,MAAM,CAChF,OAASW,EAAG,CACVkB,EAAO,YAAc,eAAelB,EAAE,OAAO,EAC/C,CACF,GAAG,EAEH,IAAMyD,EAAW,SAAY,CAC3B,IAAMJ,EAAQH,EAAM,MAAM,KAAK,EAC/B,GAAI,GAACG,GAAS,CAAChE,GACf,CAAAwD,EAAI,SAAW,GACf3B,EAAO,YAAc,wBACrB1B,EAAQ,UAAY,GACpB,GAAI,CACF,IAAMiD,EAAQ,MAAM3D,EAAauE,EAAOpF,CAAM,EACxCmE,EAASjD,EAAWsD,EAAMpD,EAC9B,CAAE,UAAA1C,EAAW,MAAAC,EAAO,YAAa,KAAM,cAAe,IAAK,CAAC,EAGxD8G,EAAa,IAAI,IACjBC,EAAe,CAAC,EACtB,OAAW,CAAE,MAAA7F,CAAM,IAAKsE,EACjBsB,EAAW,IAAI5F,CAAK,IAAK4F,EAAW,IAAI5F,CAAK,EAAG6F,EAAa,KAAK7F,CAAK,GAG9E,IAAM8F,EAAQ,IAAM,KAAK,MAAM,KAAK,OAAO,EAAI,mBAAkB,EAAE,SAAS,EAAE,EAAE,SAAS,GAAI,GAAG,EAC1FC,EAAeF,EAAa,IAAIG,GAAK,OAAOA,CAAC,IAAI,EAAE,KAAK;AAAA,CAAI,EAE5DC,EAAQ,CACZ,CAAE,KAAM,WAAY,GAAIH,EAAM,EAAG,KAAM;AAAA;AAAA,EAAsBC,CAAY,EAAG,EAC5E,CAAE,KAAM,WAAY,GAAID,EAAM,EAAG,KAAM,mBAAoB,EAC3D,GAAGxB,EAAO,IAAI,CAAC,CAAE,OAAAzD,EAAQ,KAAAK,EAAM,MAAAlB,EAAO,MAAA6C,CAAM,KAAO,CACjD,KAAM,YAAa,GAAIiD,EAAM,EAAG,KAAMjF,EAAQ,KAAAK,EAAM,MAAAlB,EACpD,KAAM,SAAS6C,EAAM,QAAQ,CAAC,CAAC,EACjC,EAAE,CACJ,EAEM2C,EAAU,OAAO,KAAK,QAAQ,CAAE,MAAO,GAAGD,CAAK,WAAY,MAAAU,EAAO,QAAS,CAAC,CAAE,CAAC,EACrF,OAAO,KAAK,WAAWT,EAAS,CAAE,MAAOxC,EAAI,QAAQ,OAAO,CAAE,CAAC,EAE/DI,EAAO,YAAc,GAAGkB,EAAO,MAAM,eACrC/B,EAAW7B,EAAM,CAAE,OAAA4D,EAAQ,MAAAiB,CAAM,CAAC,CACpC,OAASrD,EAAG,CACVkB,EAAO,YAAc,UAAUlB,EAAE,OAAO,EAC1C,QAAE,CACA6C,EAAI,SAAW,EACjB,EACF,EAEAA,EAAI,iBAAiB,QAASY,CAAQ,EACtCP,EAAM,iBAAiB,UAAWlD,GAAK,CAAMA,EAAE,MAAQ,SAASyD,EAAS,CAAE,CAAC,CAE9E,KAAO,CAEL,IAAMP,EAAUpC,EAAI,KAAK,YAAY,EAAE,CAAC,EAClC+B,EAAU/B,EAAI,KAAK,UAAU,EAAE,CAAC,EAChCtB,EAAUsB,EAAI,KAAK,cAAc,EAAE,CAAC,EACtCzB,EAAgB,KAGhB8B,GAAO,SACT+B,EAAM,MAAQ/B,EAAM,OAAS,GAC7B3B,EAAQ,UAAY2B,EAAM,OAAO,IAAI,CAAC,CAAE,OAAAxC,EAAQ,KAAAK,EAAM,MAAAlB,EAAO,MAAA6C,CAAM,IACjE,2BAA2BC,EAAQjC,EAAQK,EAAMlB,EAAO6C,CAAK,CAAC,QAAQ,EAAE,KAAK,EAAE,EAC/E,4BAA4BQ,EAAM,OAAO,MAAM,SAASA,EAAM,OAAS,EAAE,IAAIQ,EAAUR,EAAM,EAAE,CAAC,OAClGD,EAAO,YAAc,KAGrB,SAAY,CACZ,GAAI,CACGC,IAAOD,EAAO,YAAc,2BACjC7B,EAAgB,MAAMS,EAAkBpD,EAAOuB,CAAM,EACrD,IAAMuE,EAAQnD,EAAc,OAAO,CAAC2C,EAAGhC,IAAMgC,EAAIhC,EAAE,MAAM,OAAQ,CAAC,EAClEkB,EAAO,YAAcE,EAAc,eAAgBoB,EAAOnD,EAAc,MAAM,CAChF,OAASW,EAAG,CACVkB,EAAO,YAAc,eAAelB,EAAE,OAAO,EAC/C,CACF,GAAG,EAEH,IAAMgE,EAAW,SAAY,CAC3B,IAAMX,EAAQH,EAAM,MAAM,KAAK,EAC/B,GAAI,GAACG,GAAS,CAAChE,GACf,CAAAwD,EAAI,SAAW,GACf3B,EAAO,YAAc,wBACrB1B,EAAQ,UAAY,GACpB,GAAI,CACF,IAAMiD,EAAO,MAAM3D,EAAauE,EAAOpF,CAAM,EACvCmE,EAASjD,EAAWsD,EAAMpD,EAC9B,CAAE,UAAA1C,EAAW,MAAAC,EAAO,YAAa,KAAM,cAAe,IAAK,CAAC,EAC9D4C,EAAQ,UAAY4C,EAAO,IAAI,CAAC,CAAE,OAAAzD,EAAQ,KAAAK,EAAM,MAAAlB,EAAO,MAAA6C,CAAM,IAC3D,2BAA2BC,EAAQjC,EAAQK,EAAMlB,EAAO6C,CAAK,CAAC,QAAQ,EAAE,KAAK,EAAE,EAC/E,4BAA4ByB,EAAO,MAAM,SAASiB,CAAK,QACzDnC,EAAO,YAAc,GACrBb,EAAW7B,EAAM,CAAE,OAAA4D,EAAQ,MAAAiB,CAAM,CAAC,CACpC,OAASrD,EAAG,CACVkB,EAAO,YAAc,UAAUlB,EAAE,OAAO,EAC1C,QAAE,CACA6C,EAAI,SAAW,EACjB,EACF,EAEAA,EAAI,iBAAiB,QAASmB,CAAQ,EACtCd,EAAM,iBAAiB,UAAWlD,GAAK,CAAMA,EAAE,MAAQ,SAASgE,EAAS,CAAE,CAAC,CAC9E,CACF,EAII,OAAO,OAAW,MACpB,OAAO,QAAU,OAAO,SAAW,CAAC,EACpC,OAAO,QAAQ,WAAa,CAAE,KAAAnD,GAAM,KAAAG,EAAK",
|
|
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.
|
|
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",
|
|
@@ -26,6 +26,10 @@
|
|
|
26
26
|
"type": "git",
|
|
27
27
|
"url": "git+https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-similarity.git"
|
|
28
28
|
},
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-similarity/issues"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-similarity#readme",
|
|
29
33
|
"engines": {
|
|
30
34
|
"node": ">=18.x"
|
|
31
35
|
},
|
|
@@ -37,6 +41,10 @@
|
|
|
37
41
|
"test": "node --test",
|
|
38
42
|
"prepublishOnly": "npm run build"
|
|
39
43
|
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@xenova/transformers": "^2.17.2",
|
|
46
|
+
"minisearch": "^7.2.0"
|
|
47
|
+
},
|
|
40
48
|
"devDependencies": {
|
|
41
49
|
"esbuild": "^0.25.0"
|
|
42
50
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
{
|
|
10
10
|
"type": "markdown",
|
|
11
11
|
"id": "a3f7c8d2e1b94056",
|
|
12
|
-
"text": "**Version:** 0.
|
|
12
|
+
"text": "**Version:** 0.5.0 — server-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
|
|
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
|
|
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,139 +1,54 @@
|
|
|
1
1
|
// wiki-plugin-similarity — server-side component
|
|
2
2
|
//
|
|
3
|
-
// Registers
|
|
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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// req.hostname selects the domain in a fedwiki farm.
|
|
27
|
+
// Farm root is derived from argv.status ({farm}/{domain}/status); extra farm
|
|
28
|
+
// roots come from WIKI_EXTRA_FARMS (colon-separated absolute paths).
|
|
14
29
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// Farm root is derived from argv.status which is {farm}/{domain}/status.
|
|
20
|
-
|
|
21
|
-
import fs from 'node:fs'
|
|
22
|
-
import path from 'node:path'
|
|
23
|
-
import http from 'node:http'
|
|
24
|
-
|
|
25
|
-
const EMBED_URL = process.env.WIKI_EMBED_URL || 'http://localhost:8000/embed'
|
|
26
|
-
// Optional additional farm roots to search when a domain's vectors aren't found
|
|
27
|
-
// in the primary farm. Colon-separated list of absolute paths, e.g.:
|
|
28
|
-
// WIKI_EXTRA_FARMS=/Users/david/Nextcloud/fedwiki:/Users/david/other-farm
|
|
29
|
-
const EXTRA_FARMS = (process.env.WIKI_EXTRA_FARMS || '').split(':').filter(Boolean)
|
|
30
|
-
|
|
31
|
-
// ── Glob matching ─────────────────────────────────────────────────────────────
|
|
32
|
-
// Supports * (any chars) and ? (one char). No path separator semantics needed.
|
|
33
|
-
|
|
34
|
-
const globMatch = (pattern, str) => {
|
|
35
|
-
const p = pattern.length
|
|
36
|
-
const s = str.length
|
|
37
|
-
const dp = Array.from({ length: p + 1 }, () => new Array(s + 1).fill(false))
|
|
38
|
-
dp[0][0] = true
|
|
39
|
-
for (let i = 1; i <= p; i++) {
|
|
40
|
-
if (pattern[i - 1] === '*') dp[i][0] = dp[i - 1][0]
|
|
41
|
-
}
|
|
42
|
-
for (let i = 1; i <= p; i++) {
|
|
43
|
-
for (let j = 1; j <= s; j++) {
|
|
44
|
-
if (pattern[i - 1] === '*') {
|
|
45
|
-
dp[i][j] = dp[i - 1][j] || dp[i][j - 1]
|
|
46
|
-
} else if (pattern[i - 1] === '?' || pattern[i - 1] === str[j - 1]) {
|
|
47
|
-
dp[i][j] = dp[i - 1][j - 1]
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
return dp[p][s]
|
|
52
|
-
}
|
|
30
|
+
// CommonJS on purpose (see sibling server/package.json): wiki-server's older
|
|
31
|
+
// require() loader throws ERR_REQUIRE_ESM on an ESM server.js and swallows the
|
|
32
|
+
// error, silently dropping these routes. CJS loads under every Node / wiki
|
|
33
|
+
// version, while the plugin's root package stays "type":"module".
|
|
53
34
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
// LOCAL domains in the primary (localhost) farm
|
|
58
|
-
// PRIVATE public domains marked "restricted": true in the farm config
|
|
59
|
-
|
|
60
|
-
// Collect restricted domains from config-*.json files at each public farm root
|
|
61
|
-
const loadRestricted = () => {
|
|
62
|
-
const restricted = new Set()
|
|
63
|
-
for (const farm of EXTRA_FARMS) {
|
|
64
|
-
let files
|
|
65
|
-
try { files = fs.readdirSync(farm) } catch { continue }
|
|
66
|
-
for (const f of files) {
|
|
67
|
-
if (!/^config-.*\.json$/.test(f)) continue
|
|
68
|
-
try {
|
|
69
|
-
const cfg = JSON.parse(fs.readFileSync(path.join(farm, f), 'utf8'))
|
|
70
|
-
for (const [domain, opts] of Object.entries(cfg.wikiDomains || {})) {
|
|
71
|
-
if (opts && opts.restricted) restricted.add(domain)
|
|
72
|
-
}
|
|
73
|
-
} catch { /* ignore malformed config */ }
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
return restricted
|
|
77
|
-
}
|
|
78
|
-
const RESTRICTED = loadRestricted()
|
|
79
|
-
|
|
80
|
-
// kind: 'local' for the primary farm, 'public' for extra farms
|
|
81
|
-
const matchesAny = (domain, kind, patterns) =>
|
|
82
|
-
patterns.some(p => {
|
|
83
|
-
if (p === '*') return true
|
|
84
|
-
if (p === 'PUBLIC') return kind === 'public'
|
|
85
|
-
if (p === 'LOCAL') return kind === 'local'
|
|
86
|
-
if (p === 'PRIVATE') return kind === 'public' && RESTRICTED.has(domain)
|
|
87
|
-
return globMatch(p, domain)
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
// ── Multi-farm helpers ────────────────────────────────────────────────────────
|
|
35
|
+
const fs = require('node:fs')
|
|
36
|
+
const path = require('node:path')
|
|
37
|
+
const http = require('node:http')
|
|
91
38
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
for (const farm of farms) {
|
|
97
|
-
const full = path.join(farm, domain, relPath)
|
|
98
|
-
try { fs.accessSync(full, fs.constants.F_OK); return full } catch { /* try next */ }
|
|
99
|
-
}
|
|
100
|
-
return null
|
|
101
|
-
}
|
|
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')
|
|
102
43
|
|
|
103
|
-
//
|
|
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)
|
|
104
50
|
|
|
105
|
-
|
|
106
|
-
// primary farm is 'local'; extra farms (Nextcloud mirror) are 'public'
|
|
107
|
-
const farms = [[farmRoot, 'local'], ...EXTRA_FARMS.map(f => [f, 'public'])]
|
|
108
|
-
const seen = new Set()
|
|
109
|
-
const results = []
|
|
110
|
-
|
|
111
|
-
for (const [farm, kind] of farms) {
|
|
112
|
-
let entries
|
|
113
|
-
try { entries = fs.readdirSync(farm, { withFileTypes: true }) } catch { continue }
|
|
114
|
-
|
|
115
|
-
for (const ent of entries) {
|
|
116
|
-
if (!ent.isDirectory()) continue
|
|
117
|
-
const domain = ent.name
|
|
118
|
-
if (seen.has(domain)) continue // first farm wins
|
|
119
|
-
if (!matchesAny(domain, kind, patterns)) continue
|
|
120
|
-
const vecFile = path.join(farm, domain, 'status', 'semantic-vectors.json')
|
|
121
|
-
try { fs.accessSync(vecFile, fs.constants.F_OK) } catch { continue }
|
|
122
|
-
seen.add(domain)
|
|
123
|
-
let pageCount = null
|
|
124
|
-
try {
|
|
125
|
-
const pages = JSON.parse(fs.readFileSync(vecFile, 'utf8'))
|
|
126
|
-
pageCount = Array.isArray(pages) ? pages.length : null
|
|
127
|
-
} catch { /* ignore */ }
|
|
128
|
-
results.push({ domain, page_count: pageCount })
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
results.sort((a, b) => a.domain.localeCompare(b.domain))
|
|
133
|
-
return results
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// ── Fetch helper (node:http, avoids fetch() version concerns) ─────────────────
|
|
51
|
+
// ── HTTP helpers ──────────────────────────────────────────────────────────────
|
|
137
52
|
|
|
138
53
|
const postJson = (url, body) =>
|
|
139
54
|
new Promise((resolve, reject) => {
|
|
@@ -154,7 +69,7 @@ const postJson = (url, body) =>
|
|
|
154
69
|
res.on('data', chunk => { data += chunk })
|
|
155
70
|
res.on('end', () => {
|
|
156
71
|
try { resolve(JSON.parse(data)) }
|
|
157
|
-
catch (e) { reject(new Error(`
|
|
72
|
+
catch (e) { reject(new Error(`response parse error: ${e.message}`)) }
|
|
158
73
|
})
|
|
159
74
|
})
|
|
160
75
|
req.on('error', reject)
|
|
@@ -162,31 +77,64 @@ const postJson = (url, body) =>
|
|
|
162
77
|
req.end()
|
|
163
78
|
})
|
|
164
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
|
+
|
|
165
106
|
// ── startServer — called by wiki-server/lib/plugins.js ────────────────────────
|
|
166
107
|
|
|
167
|
-
|
|
108
|
+
const startServer = ({ argv, app }) => {
|
|
168
109
|
// Farm root: argv.status = {farm}/{thisDomain}/status → go up two levels
|
|
169
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 }
|
|
170
115
|
|
|
171
|
-
console.log('[wiki-plugin-similarity] registering /system routes,
|
|
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
|
+
}
|
|
172
126
|
|
|
173
|
-
// CORS helper
|
|
174
127
|
const cors = res => {
|
|
175
128
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
176
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
|
129
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
177
130
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
|
178
131
|
}
|
|
179
132
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
cors(res); res.sendStatus(204)
|
|
186
|
-
})
|
|
187
|
-
app.options('/system/embed.json', (req, res) => {
|
|
188
|
-
cors(res); res.sendStatus(204)
|
|
189
|
-
})
|
|
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
|
+
}
|
|
190
138
|
|
|
191
139
|
// ── GET /system/indexed-domains.json?pattern=glob1,glob2 ──────────────────
|
|
192
140
|
app.get('/system/indexed-domains.json', (req, res) => {
|
|
@@ -194,22 +142,25 @@ export const startServer = ({ argv, app }) => {
|
|
|
194
142
|
const raw = req.query.pattern || '*'
|
|
195
143
|
const patterns = raw.split(',').map(p => p.trim()).filter(Boolean)
|
|
196
144
|
const limit = parseInt(req.query.limit) || null
|
|
197
|
-
let results
|
|
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
|
+
})
|
|
198
155
|
if (limit) results = results.slice(0, limit)
|
|
199
156
|
res.json(results)
|
|
200
157
|
})
|
|
201
158
|
|
|
202
|
-
// ── GET /system/semantic-vectors.json
|
|
203
|
-
// Two modes:
|
|
204
|
-
// ?domain=david.hitchhikers.earth — local proxy: searches all farm roots
|
|
205
|
-
// on disk (private farm + WIKI_EXTRA_FARMS). Lets localhost clients load
|
|
206
|
-
// vectors for any domain without CORS or remote plugin requirements.
|
|
207
|
-
// (no ?domain) — req.hostname selects the domain; works for farm requests
|
|
208
|
-
// where Caddy routes each hostname to this server.
|
|
159
|
+
// ── GET /system/semantic-vectors.json[?domain=] ────────────────────────────
|
|
209
160
|
app.get('/system/semantic-vectors.json', (req, res) => {
|
|
210
161
|
cors(res)
|
|
211
162
|
const domain = req.query.domain || req.hostname || 'localhost'
|
|
212
|
-
const vecFile = findInFarms(
|
|
163
|
+
const vecFile = findInFarms(farms, domain, 'status/semantic-vectors.json')
|
|
213
164
|
if (!vecFile) {
|
|
214
165
|
return res.status(404).json({ error: `vectors not found for ${domain}` })
|
|
215
166
|
}
|
|
@@ -221,19 +172,76 @@ export const startServer = ({ argv, app }) => {
|
|
|
221
172
|
})
|
|
222
173
|
})
|
|
223
174
|
|
|
224
|
-
// ── GET /system/embed.json?text=…
|
|
175
|
+
// ── GET /system/embed.json?text=… ──────────────────────────────────────────
|
|
225
176
|
app.get('/system/embed.json', async (req, res) => {
|
|
226
177
|
cors(res)
|
|
227
178
|
const text = req.query.text
|
|
228
179
|
if (!text) return res.status(400).json({ error: 'text parameter required' })
|
|
229
180
|
try {
|
|
230
|
-
|
|
231
|
-
|
|
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)
|
|
232
198
|
} catch (e) {
|
|
233
|
-
console.error('[wiki-plugin-similarity]
|
|
234
|
-
res.status(
|
|
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}`))
|
|
239
|
+
} catch (e) {
|
|
240
|
+
res.status(502).json({ error: `farm indexer unavailable: ${e.message}` })
|
|
235
241
|
}
|
|
236
242
|
})
|
|
237
243
|
|
|
238
244
|
console.log('[wiki-plugin-similarity] routes registered')
|
|
239
245
|
}
|
|
246
|
+
|
|
247
|
+
module.exports = { startServer }
|