wiki-plugin-similarity 0.19.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- /* wiki-plugin-similarity - 0.19.1 - Fri, 04 Sep 2026 08:03:01 GMT */
1
+ /* wiki-plugin-similarity - 0.20.0 - Fri, 04 Sep 2026 09:22:03 GMT */
2
2
  (()=>{var lt={high:.78,medium:.68,low:.58},rt=lt.medium,$t=10,ct=t=>{let e=[],n=[],s=[],i=null,r=null,u="search",g=!1,k=!1,b=!1,M=null,R=null,A=null,U=null,w=(a,d)=>a===d||a.startsWith(d)&&/^[\s:]/.test(a.slice(d.length)),v=(a,d)=>a.slice(d.length).replace(/^\s*:?\s*/,"").trim();for(let a of t.split(`
3
3
  `)){let d=a.trim();if(!d||d.startsWith("#"))continue;let y=d.toUpperCase();if(w(y,"LIVE")){g=!0;continue}if(w(y,"SUBJECT")){k=!0;continue}if(w(y,"AUTHOR")){!e.length&&u==="search"&&(u="author");continue}if(w(y,"REPORT")){u==="search"&&(u="report");continue}if(w(y,"KEYWORD")){u==="search"&&(u="keyword");continue}if(w(y,"SITES")){u==="search"&&(u="sites");continue}if(w(y,"BUILD")){u==="search"&&(u="build");continue}if(w(y,"FORCE")){b=!0;continue}if(w(y,"GHOST")){M=v(d,"GHOST"),u==="search"&&(u="ghost");continue}if(w(y,"BUTTON")){R=v(d,"BUTTON");continue}if(w(y,"BATCH")){let C=v(d,"BATCH").toLowerCase();A=C==="off"||C==="no"||C==="0"?0:parseInt(C)||50;continue}if(w(y,"ALGORITHM")){U=v(d,"ALGORITHM").replace(/^\[\[|\]\]$/g,"").trim()||"search-algorithm",u==="search"&&!e.length&&(u="algorithm");continue}if(w(y,"ROSTER")){let C=v(d,"ROSTER");C&&n.push(C);continue}if(w(y,"FARM")){let C=v(d,"FARM");C&&s.push(C);continue}if(w(y,"LIST")){!e.length&&u==="search"&&(u="list");continue}if(w(y,"STATUS")){u==="search"&&(u="status");continue}if(w(y,"SIMILAR")){let C=v(y,"SIMILAR").toLowerCase();i=lt[C]||rt,!e.length&&u==="search"&&(u="similar");continue}if(w(y,"THRESHOLD")){let C=v(d,"THRESHOLD");i=lt[C.toLowerCase()]??(parseFloat(C)||rt);continue}if(w(y,"LIMIT")){r=parseInt(v(d,"LIMIT"))||$t;continue}e.push(["PUBLIC","LOCAL","PRIVATE","GALAXY","SITE"].includes(y)?y:d)}return{mode:u,specs:e,rosterRefs:n,farms:s,threshold:i??rt,limit:r??$t,live:g,subject:k,force:b,ghostUrl:M,label:R,batch:A,algorithm:U,thresholdSet:i!==null}},W=t=>t.includes("*")||t.includes("?"),Z=t=>t==="PUBLIC"||t==="LOCAL"||t==="PRIVATE"||t==="GALAXY";var bt=t=>t.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,""),dt=new Map,Wt=async(t,e)=>{if(dt.has(t))return dt.get(t);let n=`${e}/system/indexed-domains.json?pattern=${encodeURIComponent(t)}`,s=await fetch(n);if(!s.ok)throw new Error(`indexed-domains failed: ${s.status}`);let i=await s.json();return dt.set(t,i),i},K=async(t,e)=>{t.length||(t=[window.location.hostname]);let n=new Set,s=[];for(let i of t)if(i==="*"||W(i)||Z(i))for(let r of await Wt(i,e))n.has(r.domain)||(n.add(r.domain),s.push(r));else n.has(i)||(n.add(i),s.push({domain:i,page_count:null}));return s},Zt=/^([a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+|localhost)(:\d+)?$/,Xt=/^ROSTER ([A-Za-z0-9.\-:]+\/[a-z0-9-]+)$/,Qt=/^REFERENCES ([A-Za-z0-9.\-:]+\/[a-z0-9-]+)$/,yt=async t=>{let e=t.indexOf("/"),n=await fetch(`//${t.slice(0,e)}/${t.slice(e+1)}.json`);if(!n.ok)throw new Error(`roster page ${t} failed: ${n.status}`);return n.json()},xt=async(t,e,n)=>{if(n.has(t))return;n.add(t);let s;try{s=await yt(t)}catch(i){console.warn(i.message);return}for(let i of s.story||[])if(i.type==="roster")for(let r of(i.text||"").split(/\r?\n/)){let u=r.trim();if(!u)continue;let g=u.match(Zt);if(g){e.add(g[0]);continue}let k=u.match(Xt);if(k){await xt(k[1],e,n);continue}let b=u.match(Qt);if(b)try{let M=await yt(b[1]);for(let R of M.story||[])R.type==="reference"&&R.site&&e.add(R.site)}catch(M){console.warn(M.message)}}},te=async t=>{let e=new Set,n=new Set;for(let s of t)await xt(s,e,n);return[...e]},St=async(t,e)=>{let n=typeof window<"u"&&window.location?window.location.hostname:null,s=t.map(i=>i.toUpperCase()==="SITE"&&n?n:i);return e.length?[...s,...await te(e)]:s};var ut=new Map,ee=t=>`${window.location.origin}/system/semantic-vectors.json?domain=${encodeURIComponent(t)}`,vt=async t=>{if(ut.has(t))return ut.get(t);let e=await fetch(ee(t));if(!e.ok)return[];let n=await e.json();return ut.set(t,n),n},X=async(t,e)=>{let n=await fetch(`${e}/system/embed.json`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({text:t})});if(!n.ok)throw new Error(`embed failed: ${n.status}`);return(await n.json()).vector},kt=async(t,e)=>{let s=(await vt(e)).find(i=>i.slug===t);return s?s.vector:null},Q=(t,e,{threshold:n,limit:s,excludeSlug:i,excludeDomain:r})=>{let u=[];for(let{domain:g,pages:k}of e)for(let{slug:b,title:M,vector:R}of k){if(b===i&&g===r)continue;let A=0;for(let U=0;U<t.length;U++)A+=t[U]*R[U];A>=n&&u.push({domain:g,slug:b,title:M,score:A})}return u.sort((g,k)=>k.score-g.score),u.slice(0,s)},tt=async(t,e)=>{let n=await K(t,e);return(await Promise.all(n.map(async({domain:i})=>({domain:i,pages:await vt(i)})))).filter(i=>i.pages.length>0)};var Lt=t=>{let e=t.parents(".page:first"),n=e.prev(".page"),s=n.length?n:e,i=(s.attr("id")||"").split("_rev")[0],r=s.data("site")||window.location.hostname,u=s.data("data")||{},g=u.title||s.find(".title").text().trim()||i,k=[g,...(u.story||[]).filter(b=>b.type==="markdown"||b.type==="paragraph").map(b=>(b.text||"").trim()).filter(Boolean)].join(`
4
4
  `).slice(0,2e3);return{slug:i,site:r,title:g,text:k,isSelf:!n.length}},B=t=>`Subject: ${t.title}${t.isSelf?" (this page)":` @ ${t.site}`}`;var Et=t=>`sim-cache-${t}`,jt=t=>{try{let e=JSON.parse(localStorage.getItem(Et(t.id))||"null");return e?.text===(t.text||"")?e:null}catch{return null}},Y=(t,e)=>{try{localStorage.setItem(Et(t.id),JSON.stringify({text:t.text||"",ts:Date.now(),...e}))}catch{}},Ct=t=>{let e=Math.floor((Date.now()-t)/1e3);return e<60?`${e}s ago`:e<3600?`${Math.floor(e/60)}m ago`:e<86400?`${Math.floor(e/3600)}h ago`:`${Math.floor(e/86400)}d ago`};var ne=()=>{let t=new Set;try{for(let e of Object.keys(window.wiki?.neighborhood||{}))t.add(e);document.querySelectorAll(".page").forEach(e=>{let n=window.$?window.$(e).data("site"):null;t.add(n||window.location.hostname)})}catch{}return[...t]},Tt=async(t,e)=>{let n=await fetch(t,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(!n.ok){let s=new Error(`${t.split("/").pop()} failed: ${n.status}`);throw s.status=n.status,s.body=await n.json().catch(()=>({})),s}return n.json()},se=(t,e,n)=>{let s=t.slice(0,n).map(r=>`${r.site} ${r.slug}`).join("|"),i=e.slice(0,n).map(r=>`${r.site} ${r.slug}`).join("|");return s===i},It=async t=>{let{origin:e,query:n,seeded:s={},specs:i,limit:r,batch:u,algorithm:g,render:k}=t,b=q=>t.status&&t.status(q),M={roster:[...t.roster||[],...g&&g.liked||[]],neighborhood:ne(),followed:!0,...t.prefer||{}};b("Ranking sites\u2026");let R=await Tt(`${e}/system/site-rank.json`,{query:n,...s,domains:i,prefer:M,algorithm:g||{},batch:u||50}),A=R.batches||[],w={searched:0,total:R.count||A.reduce((q,F)=>q+F.length,0),batches:A.length,done:0,stopped:!1,converged:!1,running:!0,unindexed:R.unindexed||[]},v=new Map,a=[],d=0,y=()=>{w.stopped=!0};w.stop=y;let C=async q=>{let F={query:n,...s,domains:q,limit:Math.max(r*3,30),flat:!0};t.thresholdSet&&(F.threshold=t.threshold);let m=await Tt(`${e}/system/search-report.json`,F);for(let h of m.results||[]){let l=`${h.site} ${h.slug}`,o=v.get(l);(!o||o.score<h.score)&&v.set(l,h)}let p=[...v.values()].sort((h,l)=>l.score-h.score),c=se(a,p,r);a=p,w.searched+=q.length,w.done+=1,d=c?d+1:0},V=()=>A.slice(w.done),_=async(q=!1)=>{for(w.running=!0,w.stopped=!1;w.done<A.length&&!w.stopped;)if(await C(A[w.done]),k(a,w),!q&&d>=3&&w.done<A.length){w.converged=!0;break}return w.running=!1,k(a,w),a};return w.continueAll=()=>(w.converged=!1,d=-1/0,_(!0)),await _(!1),{results:a,state:w,remaining:V}};var nt="similarity:learned",oe=new Set(["liked","visited","neighbourhood","neighborhood","followed","centroid","fresh"]),et=/^([a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+|localhost)(:\d+)?$/,ht=t=>{let e={weights:{},always:[],never:[],batch:null};for(let n of(t||"").split(`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-plugin-similarity",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "description": "Federated Wiki plugin — semantic similarity search across wiki domains using prebuilt vector indexes",
5
5
  "keywords": [
6
6
  "wiki",
@@ -101,10 +101,15 @@
101
101
  "id": "572d1df31653a684",
102
102
  "text": "0.19.1 makes an explicit-domain batch cheap. A scope of fifty named domains was matched against every directory of a 1,400-site tree with the glob matcher, which builds a matrix per comparison \u2014 a second and a half per batch on the Pi5. A pattern with no wildcard is now compared by equality."
103
103
  },
104
+ {
105
+ "type": "markdown",
106
+ "id": "d3a90f5b71c26e48",
107
+ "text": "0.20.0 answers **SITES over GALAXY** by the stored site vector first. Asking which site a page belongs on used to read every page vector of every federation site \u2014 104,000 of them to rank a few hundred things. The Site Index has one vector per site, so the field is narrowed to the 250 closest by centroid before any page is read, and only those are read in full for the evidence a placement answer shows. Measured on the Pi5: 971 sites considered, 250 read, 104,425 pages down to 72,910, and 2.7 s down to **0.49 s**, with the top five identical to the full scan.\n\nThe ranking here is centroid **only**. `rankSites` normally carries the reader's preferences \u2014 followed tier, rosters, learned scores \u2014 which is right for choosing what to search first for a person and wrong for placement: with them on, a followed-tier bonus pushed the second-best site out of the field and pulled in one ranked #108. Farm scope is left scanning everything: it is ours, it is bounded, and a diluted centroid should not hide a site you own."
108
+ },
104
109
  {
105
110
  "type": "markdown",
106
111
  "id": "21eb5354d2adb29e",
107
- "text": "**Version:** 0.19.1\n\n# Links\n\n- [https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-similarity GitHub repository]\n- [https://www.npmjs.com/package/wiki-plugin-similarity npm package]\n- [https://plugin.fedwiki.club/similarity-plugin.html Plugin documentation]\n- [https://search.fedwiki.club/ Search hub] \u2014 architecture, indexing, freshness."
112
+ "text": "**Version:** 0.20.0\n\n# Links\n\n- [https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-similarity GitHub repository]\n- [https://www.npmjs.com/package/wiki-plugin-similarity npm package]\n- [https://plugin.fedwiki.club/similarity-plugin.html Plugin documentation]\n- [https://search.fedwiki.club/ Search hub] \u2014 architecture, indexing, freshness."
108
113
  }
109
114
  ],
110
115
  "plugin": "similarity",
@@ -24,6 +24,8 @@ const path = require('node:path')
24
24
  const crypto = require('node:crypto')
25
25
 
26
26
  const { listDomains } = require('./farm-lib')
27
+ const { loadSiteIndex } = require('./site-index')
28
+ const { rankSites: rankByCentroid } = require('./site-rank')
27
29
  const { loadVectors, dot, seedVector } = require('./search-report')
28
30
 
29
31
  const SITE_TOP_K = 5 // neighbourhood size
@@ -33,6 +35,23 @@ const SITE_FLOOR = 0.50 // sites whose topK falls below → dropped
33
35
  const HIT_THRESHOLD = 0.55 // evidence count, matches search-report's floor
34
36
  const TOP_PAGES_SHOWN = 3
35
37
 
38
+ // Resource selection for the federation. Answering "which SITE" by reading
39
+ // every page vector of every site means 104,000 reads to rank a few hundred
40
+ // things — and beyond our own farm those are other people's wikis (see the
41
+ // Polite Index Plan). With the Site Index there is one stored vector per site,
42
+ // so the field can be narrowed before any page is touched. Only the strongest
43
+ // sites are then read in full, because the evidence a placement answer shows —
44
+ // topK, hits, the pages themselves — still needs real pages.
45
+ //
46
+ // Farm scope is left alone: it is ours, it is bounded, and a diluted centroid
47
+ // on a broad person-pod should not hide a site we own.
48
+ const PRESELECT_MIN_DOMAINS = 120 // below this, scanning everything is cheap
49
+ // Wide enough that a site whose pages answer well is not lost because its
50
+ // centroid is diluted: on the pattern-language query the true top five sat at
51
+ // centroid ranks 1, 3, 26, 104 and 108, so a narrow field would have dropped
52
+ // two of them. 250 of 1,385 keeps the answer and still skips three quarters.
53
+ const PRESELECT_KEEP = 250 // sites read in full afterwards
54
+
36
55
  const makeId = () => crypto.randomBytes(8).toString('hex')
37
56
 
38
57
  // ── Scoring — one pass per site over vectors already in memory ───────────────
@@ -150,6 +169,35 @@ const buildSiteReport = async (query, specs, limit,
150
169
  if (exclude) domains = domains.filter(d => !exclude.has(d.domain))
151
170
  const qvec = await seedVector(seedOpts, query, farms, embed)
152
171
 
172
+ // Narrow by stored site vector before reading any pages.
173
+ const reachesGalaxy = useSpecs.some(s2 => String(s2).toUpperCase() === 'GALAXY')
174
+ let considered = null
175
+ if (reachesGalaxy && domains.length > PRESELECT_MIN_DOMAINS) {
176
+ // farms is [[rootPath, kind], …]; the galaxy tree is the one kind 'galaxy'.
177
+ const galaxyDir = (farms.find(([, kind]) => kind === 'galaxy') || [])[0] || null
178
+ const index = loadSiteIndex(galaxyDir)
179
+ if (index) {
180
+ // Centroid alone. rankSites also carries the reader's preferences — the
181
+ // followed tier, rosters, learned scores — which is right for deciding
182
+ // what to search first for a person, and wrong here: "which site should
183
+ // this page live on" is not a question about whose wikis you like. Left
184
+ // personalised, a followed-tier bonus of 0.2 swamped centroid gaps and
185
+ // pushed the second-best site (0.890) out of the field while pulling in
186
+ // one ranked #108. Weights zeroed so only topical fit orders the field.
187
+ const OBJECTIVE = { weights: { liked: 0, visited: 0, neighbourhood: 0,
188
+ followed: 0, centroid: 1, fresh: 0 } }
189
+ const order = new Map(
190
+ rankByCentroid(qvec, index, [], {}, OBJECTIVE).map((r, i) => [r.domain, i]))
191
+ const known = domains.filter(d => order.has(d.domain))
192
+ // A site the index has not caught up with is not silently dropped; it
193
+ // keeps its place behind the ranked ones rather than losing its turn.
194
+ const unknown = domains.filter(d => !order.has(d.domain))
195
+ known.sort((a, b) => order.get(a.domain) - order.get(b.domain))
196
+ considered = domains.length
197
+ domains = known.concat(unknown).slice(0, PRESELECT_KEEP)
198
+ }
199
+ }
200
+
153
201
  const ex = seedOpts.excludePage
154
202
  let totalPages = 0
155
203
  const entries = domains.map(({ farm, domain }) => {
@@ -174,6 +222,8 @@ const buildSiteReport = async (query, specs, limit,
174
222
  }))
175
223
 
176
224
  const stats = { domains: domains.length, pages: totalPages, above_floor: ranked.length }
225
+ // Say so when most of the field was judged on its site vector alone.
226
+ if (considered !== null) { stats.considered = considered; stats.preselected = true }
177
227
  if (format === 'flat') return { query, scanned: stats, sites }
178
228
  return siteReportPage(query, sites, stats, limit, useSpecs)
179
229
  }