wiki-plugin-similarity 0.1.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.
@@ -0,0 +1,38 @@
1
+ /* wiki-plugin-similarity - 0.1.0 - Tue, 09 Jun 2026 19:28:16 GMT */
2
+ (()=>{var j={high:.78,medium:.68,low:.58},v=j.medium,E=10,R=t=>{let e=[],s=null,n=null,i="search";for(let c of t.split(`
3
+ `)){let o=c.trim();if(!o||o.startsWith("#"))continue;let a=o.toUpperCase();if(a.startsWith("SIMILAR:")){let p=o.split(":")[1].trim().toLowerCase();s=j[p]??v,!e.length&&i==="search"&&(i="similar");continue}if(a.startsWith("THRESHOLD:")){s=parseFloat(o.split(":")[1])||v;continue}if(a.startsWith("LIMIT:")){n=parseInt(o.split(":")[1])||E;continue}e.push(o)}return{mode:i,specs:e,threshold:s??v,limit:n??E}},z=t=>t.includes("*")||t.includes("?"),F=t=>t.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,""),L=new Map,U=async(t,e)=>{if(L.has(t))return L.get(t);let s=`${e}/system/indexed-domains.json?pattern=${encodeURIComponent(t)}`,n=await fetch(s);if(!n.ok)throw new Error(`indexed-domains failed: ${n.status}`);let i=await n.json();return L.set(t,i),i},W=async(t,e)=>{t.length||(t=[window.location.hostname]);let s=new Set,n=[];for(let i of t)if(i==="*"||z(i))for(let c of await U(i,e))s.has(c.domain)||(s.add(c.domain),n.push(c));else s.has(i)||(s.add(i),n.push({domain:i,page_count:null}));return n},S=new Map,_=()=>{let t=window.location.hostname;return t==="localhost"||t.endsWith(".localhost")||t==="127.0.0.1"},A=t=>_()?`${window.location.origin}/system/semantic-vectors.json?domain=${encodeURIComponent(t)}`:`http://${t}/system/semantic-vectors.json`,H=async t=>{if(S.has(t))return S.get(t);let e=await fetch(A(t));if(!e.ok)return[];let s=await e.json();return S.set(t,s),s},k=async(t,e)=>{let s=await fetch(`${e}/system/embed.json?text=${encodeURIComponent(t)}`);if(!s.ok)throw new Error(`embed failed: ${s.status}`);return(await s.json()).vector},q=async(t,e)=>{let n=(await H(e)).find(i=>i.slug===t);return n?n.vector:null},C=(t,e,{threshold:s,limit:n,excludeSlug:i,excludeDomain:c})=>{let o=[];for(let{domain:a,pages:p}of e)for(let{slug:u,title:r,vector:d}of p){if(u===i&&a===c)continue;let f=0;for(let m=0;m<t.length;m++)f+=t[m]*d[m];f>=s&&o.push({domain:a,slug:u,title:r,score:f})}return o.sort((a,p)=>p.score-a.score),o.slice(0,n)},D=async(t,e)=>{let s=await W(t,e);return(await Promise.all(s.map(async({domain:i})=>({domain:i,pages:await H(i)})))).filter(i=>i.pages.length>0)},T=`
4
+ .sim-form { display:flex; gap:6px; margin-bottom:8px; }
5
+ .sim-input { flex:1; padding:6px 8px; font-size:14px; border:1px solid #ccc; border-radius:3px; }
6
+ .sim-btn { padding:6px 14px; background:#c4561d; color:white; border:none;
7
+ border-radius:3px; cursor:pointer; font-size:14px; }
8
+ .sim-btn:disabled { opacity:0.5; cursor:not-allowed; }
9
+ .sim-status { font-size:12px; color:#888; margin-bottom:6px; min-height:16px; }
10
+ .sim-results { margin-top:4px; }
11
+ .sim-result { display:flex; align-items:center; gap:8px; padding:3px 0;
12
+ border-bottom:1px solid #f0f0f0; }
13
+ .sim-flag { width:16px; height:16px; vertical-align:middle; margin-right:4px; }
14
+ .sim-link { font-size:14px; color:#406; flex:1; }
15
+ .sim-domain { font-size:11px; color:#999; }
16
+ .similar-results h3 { margin:4px 0 6px; font-size:14px; color:#555; }
17
+ .similar-results ul { margin:0; padding-left:18px; }
18
+ .similar-results li { font-size:14px; padding:2px 0; }
19
+ .similar-results .sim-domain { margin-left:6px; }
20
+ `,O=(t,e)=>`<img class="sim-flag remote" src="${window.wiki.site(t).flag()}"
21
+ title="${t} \u2014 score ${e.toFixed(3)}"
22
+ data-site="${t}">`,I=(t,e,s,n)=>`<a class="sim-link" data-title="${s}" data-slug="${e}" data-site="${t}" href="#">${O(t,n)} ${s}</a>`,P=(t,e)=>{let{mode:s,specs:n,threshold:i,limit:c}=R(e?.text||"");if(s==="similar"){let o=n.length?n.join(", "):"current domain";t.html(`
23
+ <style>${T}</style>
24
+ <div class="similarity" data-id="${e.id}">
25
+ <div class="sim-status">Finding similar pages across ${o}\u2026</div>
26
+ <div class="sim-results"></div>
27
+ </div>`)}else{let o=n.length?n.join(", "):"(current domain)";t.html(`
28
+ <style>${T}</style>
29
+ <div class="similarity" data-id="${e.id}">
30
+ <div class="sim-form">
31
+ <input class="sim-input" type="text" placeholder="Search wiki pages\u2026" />
32
+ <button class="sim-btn">Search</button>
33
+ </div>
34
+ <div class="sim-status">Domains: ${o}</div>
35
+ <div class="sim-results"></div>
36
+ </div>`)}},V=(t,e)=>{let{mode:s,specs:n,threshold:i,limit:c}=R(e?.text||""),o=window.location.origin,a=t.find(".sim-status")[0],p=t.find(".sim-results")[0];if(t.on("dblclick",()=>window.wiki.textEditor(t,e)),t.on("click",".sim-link",function(u){u.preventDefault();let r=$(this);window.wiki.doInternalLink(r.data("title"),t.parents(".page"),r.data("site"))}),s==="similar")(async()=>{try{let r=t.parents(".page"),d=r.find(".title").text().trim()||document.title,f=F(d),m=window.location.hostname,l=await D(n,o),h=l.reduce((x,w)=>x+w.pages.length,0);a.textContent=`Searching ${h.toLocaleString()} pages\u2026`;let g=await q(f,m);if(!g){a.textContent="Embedding page (not yet indexed)\u2026";let x=r.find(".item").map((w,b)=>$(b).text().trim()).get().filter(Boolean).join(`
37
+ `);g=await k(x||d,o)}let y=C(g,l,{threshold:i,limit:c,excludeSlug:f,excludeDomain:m});if(!y.length){a.textContent=`No similar pages found above threshold ${i}`;return}p.innerHTML=`<h3>Similar Pages</h3><ul>${y.map(({domain:x,slug:w,title:b,score:M})=>`<li>${I(x,w,b,M)}</li>`).join("")}</ul>`,a.textContent=`${y.length} similar pages found`}catch(r){a.textContent=`Error: ${r.message}`}})();else{let u=t.find(".sim-input")[0],r=t.find(".sim-btn")[0],d=null;(async()=>{try{a.textContent="Resolving domains\u2026",d=await D(n,o);let l=d.reduce((h,g)=>h+g.pages.length,0);a.textContent=`Ready \u2014 ${l.toLocaleString()} pages across ${d.length} domains`}catch(l){a.textContent=`Load error: ${l.message}`}})();let m=async()=>{let l=u.value.trim();if(!(!l||!d)){r.disabled=!0,a.textContent="Embedding query\u2026",p.innerHTML="";try{let h=await k(l,o),g=C(h,d,{threshold:0,limit:c,excludeSlug:null,excludeDomain:null});p.innerHTML=g.map(({domain:y,slug:x,title:w,score:b})=>`<div class="sim-result">${I(y,x,w,b)}</div>`).join(""),a.textContent=`Top ${g.length} results for "${l}"`}catch(h){a.textContent=`Error: ${h.message}`}finally{r.disabled=!1}}};r.addEventListener("click",m),u.addEventListener("keydown",l=>{l.key==="Enter"&&m()})}};typeof window<"u"&&(window.plugins=window.plugins||{},window.plugins.similarity={emit:P,bind:V});})();
38
+ //# sourceMappingURL=similarity.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 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// 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 DSL (Ward's ALL-CAPS convention):\n// SIMILAR: as the FIRST meaningful line \u2192 ambient mode (no search form;\n// the item automatically finds pages similar to the current page)\n// Domain specs first, or empty text \u2192 search form mode (user types query)\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\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 (upper.startsWith('SIMILAR:')) {\n const level = line.split(':')[1].trim().toLowerCase()\n threshold = SIMILAR_THRESHOLDS[level] ?? DEFAULT_THRESHOLD\n // SIMILAR: as the first meaningful line \u2192 ambient mode\n if (!specs.length && mode === 'search') mode = 'similar'\n continue\n }\n if (upper.startsWith('THRESHOLD:')) {\n threshold = parseFloat(line.split(':')[1]) || DEFAULT_THRESHOLD\n continue\n }\n if (upper.startsWith('LIMIT:')) {\n limit = parseInt(line.split(':')[1]) || DEFAULT_LIMIT\n continue\n }\n // Anything else is a domain spec\n specs.push(line)\n }\n\n return {\n mode,\n specs,\n threshold: threshold ?? DEFAULT_THRESHOLD,\n limit: limit ?? DEFAULT_LIMIT,\n }\n}\n\nconst isGlob = spec => spec.includes('*') || spec.includes('?')\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)) {\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// Detect whether the wiki is running on localhost (or a .localhost subdomain).\n// On localhost we proxy all domain vector fetches through the local server's\n// ?domain= parameter \u2014 this reads vectors directly from the farm folder on disk,\n// avoiding any need for remote servers to have this plugin installed, and\n// sidestepping CORS entirely.\nconst isLocalhost = () => {\n const h = window.location.hostname\n return h === 'localhost' || h.endsWith('.localhost') || h === '127.0.0.1'\n}\n\nconst vectorUrl = domain => {\n if (isLocalhost()) {\n // Route through our local server \u2014 serves any farm domain from disk\n return `${window.location.origin}/system/semantic-vectors.json?domain=${encodeURIComponent(domain)}`\n }\n // On a real server, fetch directly from the remote wiki\n return `http://${domain}/system/semantic-vectors.json`\n}\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 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 { 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`\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 } = parseDSL(item?.text || '')\n 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 {\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 } = parseDSL(item?.text || '')\n const origin = window.location.origin\n const status = div.find('.sim-status')[0]\n const results = div.find('.sim-results')[0]\n\n div.on('dblclick', () => window.wiki.textEditor(div, item))\n\n div.on('click', '.sim-link', function (e) {\n e.preventDefault()\n const $a = $(this)\n window.wiki.doInternalLink($a.data('title'), div.parents('.page'), $a.data('site'))\n })\n\n if (mode === 'similar') {\n // Ambient mode \u2014 run automatically, no search form\n const run = 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\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>`\n status.textContent = `${scored.length} similar pages found`\n } catch (e) {\n status.textContent = `Error: ${e.message}`\n }\n }\n run()\n } else {\n // Search form mode \u2014 preload domains, then wait for user input\n const input = div.find('.sim-input')[0]\n const btn = div.find('.sim-btn')[0]\n let domainEntries = null\n\n const preload = async () => {\n try {\n 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 = `Ready \u2014 ${total.toLocaleString()} pages across ${domainEntries.length} domains`\n } catch (e) {\n status.textContent = `Load error: ${e.message}`\n }\n }\n preload()\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: 0, 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 status.textContent = `Top ${scored.length} results for \"${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", "raw", "line", "upper", "level", "isGlob", "spec", "slugify", "title", "domainCache", "resolveDomainsForSpec", "origin", "url", "res", "list", "resolveDomains", "seen", "result", "item", "vectorCache", "isLocalhost", "h", "vectorUrl", "domain", "loadVectors", "data", "getEmbedding", "lookupPageVector", "slug", "entry", "p", "cosineScan", "queryVec", "domainEntries", "excludeSlug", "excludeDomain", "results", "pages", "vector", "dot", "i", "b", "loadDomainEntries", "domains", "e", "STYLES", "siteFlag", "score", "simLink", "emit", "div", "label", "bind", "status", "$a", "$page", "pageTitle", "currentSlug", "currentDomain", "total", "n", "qVec", "pageText", "_", "el", "scored", "input", "btn", "doSearch", "query"]
7
+ }
package/factory.json ADDED
@@ -0,0 +1 @@
1
+ {"name": "Similarity", "title": "Semantic similarity search across wiki domains", "category": "other"}
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "wiki-plugin-similarity",
3
+ "version": "0.1.0",
4
+ "description": "Federated Wiki plugin — semantic similarity search across wiki domains using prebuilt vector indexes",
5
+ "keywords": ["wiki", "federated wiki", "plugin", "similarity", "semantic search", "embeddings"],
6
+ "author": {
7
+ "name": "David Bovill",
8
+ "url": "https://github.com/Hitchhikers-Guide-to-the-Galaxy"
9
+ },
10
+ "license": "MIT",
11
+ "type": "module",
12
+ "files": ["client", "server", "pages", "factory.json"],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-similarity.git"
16
+ },
17
+ "engines": { "node": ">=18.x" },
18
+ "scripts": {
19
+ "build": "npm run clean && npm run test && node --no-warnings scripts/build-client.js",
20
+ "dev": "node --no-warnings scripts/build-client.js --watch",
21
+ "about": "wiki -p 3010 -d . --security_legacy",
22
+ "clean": "rm -f client/similarity.js client/similarity.js.map",
23
+ "test": "node --test",
24
+ "prepublishOnly": "npm run build"
25
+ },
26
+ "devDependencies": {
27
+ "esbuild": "^0.25.0"
28
+ }
29
+ }
@@ -0,0 +1,53 @@
1
+ {
2
+ "title": "About Similarity Plugin",
3
+ "story": [
4
+ {
5
+ "type": "markdown",
6
+ "id": "61ccb2d2cdb60c71",
7
+ "text": "The Similarity plugin adds semantic similarity search to a Federated Wiki farm. It ships two item types — **similarity** (an interactive search form) and **similar** (an ambient panel that automatically finds pages related to the one you are reading)."
8
+ },
9
+ {
10
+ "type": "similarity",
11
+ "id": "ad3c41667c285efd",
12
+ "text": "SIMILAR: medium\nLIMIT: 5"
13
+ },
14
+ {
15
+ "type": "markdown",
16
+ "id": "bdf93e3860d64ebb",
17
+ "text": "## Domain DSL"
18
+ },
19
+ {
20
+ "type": "markdown",
21
+ "id": "0d628d43edf9f8e8",
22
+ "text": "The item's `text` field configures which domains to search and how similar results must be:\n\n```\n* all indexed domains on this server\ndavid.* glob pattern\ndavid.hitchhikers.earth explicit domain\n# comment ignored\n---\nSIMILAR: high threshold preset (high=0.78 / medium=0.68 / low=0.58)\nTHRESHOLD: 0.72 exact cosine threshold\nLIMIT: 8 max results (default 10)\n```\n\nLeave `text` empty to search the current domain at the default medium threshold."
23
+ },
24
+ {
25
+ "type": "markdown",
26
+ "id": "c0775c94a895e9d2",
27
+ "text": "## How It Works"
28
+ },
29
+ {
30
+ "type": "markdown",
31
+ "id": "4b1bbe95544548eb",
32
+ "text": "Pages are pre-indexed as 384-dimensional embedding vectors using BAAI/bge-small-en-v1.5 via fastembed. The plugin loads `semantic-vectors.json` index files directly in the browser and scores them with cosine similarity in JavaScript — no remote search engine required.\n\nThe **similar** item type uses a fast path: if the current page is already indexed, its vector is reused directly without an embedding call. See [[Similarity Indexing]] for how to build and refresh indices."
33
+ }
34
+ ],
35
+ "journal": [
36
+ {
37
+ "type": "create",
38
+ "item": {
39
+ "title": "About Similarity Plugin",
40
+ "story": [
41
+ {
42
+ "type": "markdown",
43
+ "id": "61ccb2d2cdb60c71",
44
+ "text": "The Similarity plugin adds semantic similarity search to a Federated Wiki farm. It ships two item types — **similarity** (an interactive search form) and **similar** (an ambient panel that automatically finds pages related to the one you are reading)."
45
+ }
46
+ ]
47
+ },
48
+ "date": 1781026832000,
49
+ "certificate": "from marvin"
50
+ }
51
+ ],
52
+ "plugin": "similarity"
53
+ }
@@ -0,0 +1,198 @@
1
+ // wiki-plugin-similarity — server-side component
2
+ //
3
+ // Registers three routes with the wiki's Express app via the startServer(params)
4
+ // hook in wiki-server/lib/plugins.js.
5
+ //
6
+ // Routes:
7
+ // GET /system/indexed-domains.json?pattern=glob1,glob2
8
+ // Returns [{domain, page_count}] for all domains whose semantic-vectors.json
9
+ // exists and whose name matches any of the supplied glob patterns.
10
+ //
11
+ // GET /system/semantic-vectors.json
12
+ // Serves {farm}/{req.hostname}/status/semantic-vectors.json.
13
+ // req.hostname selects the domain in a fedwiki farm.
14
+ //
15
+ // GET /system/embed?text=…
16
+ // Proxies to the local FastAPI /embed endpoint and returns {vector:[…]}.
17
+ // Requires the FastAPI server to be running on EMBED_URL (default localhost:8000).
18
+ //
19
+ // Farm root is derived from argv.status which is {farm}/{domain}/status.
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
+ }
53
+
54
+ const matchesAny = (domain, patterns) =>
55
+ patterns.some(p => p === '*' || globMatch(p, domain))
56
+
57
+ // ── Multi-farm helpers ────────────────────────────────────────────────────────
58
+
59
+ // Return the first path that exists across all farm roots for a given domain
60
+ // and relative sub-path (e.g. 'status/semantic-vectors.json').
61
+ const findInFarms = (farmRoot, domain, relPath) => {
62
+ const farms = [farmRoot, ...EXTRA_FARMS]
63
+ for (const farm of farms) {
64
+ const full = path.join(farm, domain, relPath)
65
+ try { fs.accessSync(full, fs.constants.F_OK); return full } catch { /* try next */ }
66
+ }
67
+ return null
68
+ }
69
+
70
+ // ── Discover indexed domains ───────────────────────────────────────────────────
71
+
72
+ const findIndexedDomains = (farmRoot, patterns) => {
73
+ const farms = [farmRoot, ...EXTRA_FARMS]
74
+ const seen = new Set()
75
+ const results = []
76
+
77
+ for (const farm of farms) {
78
+ let entries
79
+ try { entries = fs.readdirSync(farm, { withFileTypes: true }) } catch { continue }
80
+
81
+ for (const ent of entries) {
82
+ if (!ent.isDirectory()) continue
83
+ const domain = ent.name
84
+ if (seen.has(domain)) continue // first farm wins
85
+ if (!matchesAny(domain, patterns)) continue
86
+ const vecFile = path.join(farm, domain, 'status', 'semantic-vectors.json')
87
+ try { fs.accessSync(vecFile, fs.constants.F_OK) } catch { continue }
88
+ seen.add(domain)
89
+ let pageCount = null
90
+ try {
91
+ const pages = JSON.parse(fs.readFileSync(vecFile, 'utf8'))
92
+ pageCount = Array.isArray(pages) ? pages.length : null
93
+ } catch { /* ignore */ }
94
+ results.push({ domain, page_count: pageCount })
95
+ }
96
+ }
97
+
98
+ results.sort((a, b) => a.domain.localeCompare(b.domain))
99
+ return results
100
+ }
101
+
102
+ // ── Fetch helper (node:http, avoids fetch() version concerns) ─────────────────
103
+
104
+ const postJson = (url, body) =>
105
+ new Promise((resolve, reject) => {
106
+ const payload = JSON.stringify(body)
107
+ const u = new URL(url)
108
+ const opts = {
109
+ hostname: u.hostname,
110
+ port: u.port || 80,
111
+ path: u.pathname + u.search,
112
+ method: 'POST',
113
+ headers: {
114
+ 'Content-Type': 'application/json',
115
+ 'Content-Length': Buffer.byteLength(payload),
116
+ },
117
+ }
118
+ const req = http.request(opts, res => {
119
+ let data = ''
120
+ res.on('data', chunk => { data += chunk })
121
+ res.on('end', () => {
122
+ try { resolve(JSON.parse(data)) }
123
+ catch (e) { reject(new Error(`embed response parse error: ${e.message}`)) }
124
+ })
125
+ })
126
+ req.on('error', reject)
127
+ req.write(payload)
128
+ req.end()
129
+ })
130
+
131
+ // ── startServer — called by wiki-server/lib/plugins.js ────────────────────────
132
+
133
+ export const startServer = ({ argv, app }) => {
134
+ // Farm root: argv.status = {farm}/{thisDomain}/status → go up two levels
135
+ const farmRoot = path.dirname(path.dirname(argv.status))
136
+
137
+ console.log('[wiki-plugin-similarity] registering /system routes, farm:', farmRoot)
138
+
139
+ // CORS helper
140
+ const cors = res => {
141
+ res.setHeader('Access-Control-Allow-Origin', '*')
142
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
143
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
144
+ }
145
+
146
+ // ── OPTIONS pre-flight ─────────────────────────────────────────────────────
147
+ app.options('/system/indexed-domains.json', (req, res) => {
148
+ cors(res); res.sendStatus(204)
149
+ })
150
+ app.options('/system/semantic-vectors.json', (req, res) => {
151
+ cors(res); res.sendStatus(204)
152
+ })
153
+ app.options('/system/embed.json', (req, res) => {
154
+ cors(res); res.sendStatus(204)
155
+ })
156
+
157
+ // ── GET /system/indexed-domains.json?pattern=glob1,glob2 ──────────────────
158
+ app.get('/system/indexed-domains.json', (req, res) => {
159
+ cors(res)
160
+ const raw = req.query.pattern || '*'
161
+ const patterns = raw.split(',').map(p => p.trim()).filter(Boolean)
162
+ const results = findIndexedDomains(farmRoot, patterns)
163
+ res.json(results)
164
+ })
165
+
166
+ // ── GET /system/semantic-vectors.json ─────────────────────────────────────
167
+ // Two modes:
168
+ // ?domain=david.hitchhikers.earth — local proxy: searches all farm roots
169
+ // on disk (private farm + WIKI_EXTRA_FARMS). Lets localhost clients load
170
+ // vectors for any domain without CORS or remote plugin requirements.
171
+ // (no ?domain) — req.hostname selects the domain; works for farm requests
172
+ // where Caddy routes each hostname to this server.
173
+ app.get('/system/semantic-vectors.json', (req, res) => {
174
+ cors(res)
175
+ const domain = req.query.domain || req.hostname || 'localhost'
176
+ const vecFile = findInFarms(farmRoot, domain, 'status/semantic-vectors.json')
177
+ if (!vecFile) {
178
+ return res.status(404).json({ error: `vectors not found for ${domain}` })
179
+ }
180
+ res.sendFile(vecFile)
181
+ })
182
+
183
+ // ── GET /system/embed.json?text=… ────────────────────────────────────────
184
+ app.get('/system/embed.json', async (req, res) => {
185
+ cors(res)
186
+ const text = req.query.text
187
+ if (!text) return res.status(400).json({ error: 'text parameter required' })
188
+ try {
189
+ const result = await postJson(EMBED_URL, { text })
190
+ res.json(result)
191
+ } catch (e) {
192
+ console.error('[wiki-plugin-similarity] embed proxy error:', e.message)
193
+ res.status(502).json({ error: `embed service unavailable: ${e.message}` })
194
+ }
195
+ })
196
+
197
+ console.log('[wiki-plugin-similarity] routes registered')
198
+ }