wiki-plugin-similarity 0.8.1 → 0.9.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.8.1 - Tue, 21 Jul 2026 08:15:59 GMT */
1
+ /* wiki-plugin-similarity - 0.9.0 - Wed, 22 Jul 2026 07:41:00 GMT */
2
2
  (()=>{var q={high:.78,medium:.68,low:.58},M=q.medium,_=10,V=t=>{let e=[],n=[],o=[],f=null,b=null,d="search",y=!1,L=!1,E=null,k=null,w=(i,m)=>i===m||i.startsWith(m)&&/^[\s:]/.test(i.slice(m.length)),x=(i,m)=>i.slice(m.length).replace(/^\s*:?\s*/,"").trim();for(let i of t.split(`
3
3
  `)){let m=i.trim();if(!m||m.startsWith("#"))continue;let g=m.toUpperCase();if(w(g,"LIVE")){y=!0;continue}if(w(g,"AUTHOR")){!e.length&&d==="search"&&(d="author");continue}if(w(g,"REPORT")){d==="search"&&(d="report");continue}if(w(g,"KEYWORD")){d==="search"&&(d="keyword");continue}if(w(g,"BUILD")){d==="search"&&(d="build");continue}if(w(g,"FORCE")){L=!0;continue}if(w(g,"GHOST")){E=x(m,"GHOST"),d==="search"&&(d="ghost");continue}if(w(g,"BUTTON")){k=x(m,"BUTTON");continue}if(w(g,"ROSTER")){let S=x(m,"ROSTER");S&&n.push(S);continue}if(w(g,"FARM")){let S=x(m,"FARM");S&&o.push(S);continue}if(w(g,"LIST")){!e.length&&d==="search"&&(d="list");continue}if(w(g,"SIMILAR")){let S=x(g,"SIMILAR").toLowerCase();f=q[S]||M,!e.length&&d==="search"&&(d="similar");continue}if(w(g,"THRESHOLD")){let S=x(m,"THRESHOLD");f=q[S.toLowerCase()]??(parseFloat(S)||M);continue}if(w(g,"LIMIT")){b=parseInt(x(m,"LIMIT"))||_;continue}e.push(["PUBLIC","LOCAL","PRIVATE"].includes(g)?g:m)}return{mode:d,specs:e,rosterRefs:n,farms:o,threshold:f??M,limit:b??_,live:y,force:L,ghostUrl:E,label:k,thresholdSet:f!==null}},et=t=>t.includes("*")||t.includes("?"),nt=t=>t==="PUBLIC"||t==="LOCAL"||t==="PRIVATE",st=t=>t.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,""),P=new Map,it=async(t,e)=>{if(P.has(t))return P.get(t);let n=`${e}/system/indexed-domains.json?pattern=${encodeURIComponent(t)}`,o=await fetch(n);if(!o.ok)throw new Error(`indexed-domains failed: ${o.status}`);let f=await o.json();return P.set(t,f),f},G=async(t,e)=>{t.length||(t=[window.location.hostname]);let n=new Set,o=[];for(let f of t)if(f==="*"||et(f)||nt(f))for(let b of await it(f,e))n.has(b.domain)||(n.add(b.domain),o.push(b));else n.has(f)||(n.add(f),o.push({domain:f,page_count:null}));return o},ot=/^([a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+|localhost)(:\d+)?$/,at=/^ROSTER ([A-Za-z0-9.\-:]+\/[a-z0-9-]+)$/,rt=/^REFERENCES ([A-Za-z0-9.\-:]+\/[a-z0-9-]+)$/,B=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()},K=async(t,e,n)=>{if(n.has(t))return;n.add(t);let o;try{o=await B(t)}catch(f){console.warn(f.message);return}for(let f of o.story||[])if(f.type==="roster")for(let b of(f.text||"").split(/\r?\n/)){let d=b.trim();if(!d)continue;let y=d.match(ot);if(y){e.add(y[0]);continue}let L=d.match(at);if(L){await K(L[1],e,n);continue}let E=d.match(rt);if(E)try{let k=await B(E[1]);for(let w of k.story||[])w.type==="reference"&&w.site&&e.add(w.site)}catch(k){console.warn(k.message)}}},lt=async t=>{let e=new Set,n=new Set;for(let o of t)await K(o,e,n);return[...e]},ct=async(t,e)=>e.length?[...t,...await lt(e)]:t,A=new Map,dt=t=>`${window.location.origin}/system/semantic-vectors.json?domain=${encodeURIComponent(t)}`,Z=async t=>{if(A.has(t))return A.get(t);let e=await fetch(dt(t));if(!e.ok)return[];let n=await e.json();return A.set(t,n),n},z=async(t,e)=>{let n=await fetch(`${e}/system/embed.json?text=${encodeURIComponent(t)}`);if(!n.ok)throw new Error(`embed failed: ${n.status}`);return(await n.json()).vector},ft=async(t,e)=>{let o=(await Z(e)).find(f=>f.slug===t);return o?o.vector:null},H=(t,e,{threshold:n,limit:o,excludeSlug:f,excludeDomain:b})=>{let d=[];for(let{domain:y,pages:L}of e)for(let{slug:E,title:k,vector:w}of L){if(E===f&&y===b)continue;let x=0;for(let i=0;i<t.length;i++)x+=t[i]*w[i];x>=n&&d.push({domain:y,slug:E,title:k,score:x})}return d.sort((y,L)=>L.score-y.score),d.slice(0,o)},F=async(t,e)=>{let n=await G(t,e);return(await Promise.all(n.map(async({domain:f})=>({domain:f,pages:await Z(f)})))).filter(f=>f.pages.length>0)},J=t=>`sim-cache-${t}`,mt=t=>{try{let e=JSON.parse(localStorage.getItem(J(t.id))||"null");return e?.text===(t.text||"")?e:null}catch{return null}},O=(t,e)=>{try{localStorage.setItem(J(t.id),JSON.stringify({text:t.text||"",ts:Date.now(),...e}))}catch{}},ut=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`},I=`
4
4
  .sim-form { display:flex; gap:6px; margin-bottom:8px; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-plugin-similarity",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "Federated Wiki plugin \u2014 semantic similarity search across wiki domains using prebuilt vector indexes",
5
5
  "keywords": [
6
6
  "wiki",
@@ -4,12 +4,12 @@
4
4
  {
5
5
  "type": "markdown",
6
6
  "id": "aec1dfcd5f2bc42b",
7
- "text": "The Similarity plugin adds search to a Federated Wiki farm semantic similarity over prebuilt vector indexes, ranked search reports, and galactic keyword search over each site's own MiniSearch index. One item type **similarity** with a line-oriented DSL, plus a server component that serves every search same-origin. Works on any host: embedding runs in-process (native ONNX where available, WASM everywhere else)."
7
+ "text": "The Similarity plugin adds search to a Federated Wiki farm \u2014 semantic similarity over prebuilt vector indexes, ranked search reports, and galactic keyword search over each site's own MiniSearch index. One item type \u2014 **similarity** \u2014 with a line-oriented DSL, plus a server component that serves every search same-origin. Works on any host: embedding runs in-process (native ONNX where available, WASM everywhere else)."
8
8
  },
9
9
  {
10
10
  "type": "markdown",
11
11
  "id": "28edff984cc5334d",
12
- "text": "## The DSL\n\nThe first ALL-CAPS keyword sets the mode; other lines are domain specs (globs, explicit domains, or the scope keywords PUBLIC / LOCAL / PRIVATE):\n\n- (no keyword) interactive semantic search form\n- `SIMILAR: high|medium|low` ambient: pages similar to this page\n- `REPORT` server-side ranked, stub-filtered, fork-bundled report\n- `KEYWORD` galactic MiniSearch over live site-index.json files\n- `LIST` table of indexed domains\n- `AUTHOR` search results as a draggable ghost page\n- `BUILD` / `FORCE` proxy to the farm indexer, where configured\n- `GHOST url` / `BUTTON label` open any page-json as a ghost page\n- `ROSTER site/slug` add a roster page's sites to the scope (off-farm sites searched via the galaxy cache)\n- `FARM domain` ask a peer farm to continue the search. The peer answers from exactly the sites whose own [[Federated Farm Search]] page grants your origin (`FROM` lines; `FROM *` needs the peer admin's `WIKI_PEER_FEDERATION=open`)\n- `THRESHOLD: 0.72` · `LIMIT: 8` · `LIVE` tuning"
12
+ "text": "## The DSL\n\nThe first ALL-CAPS keyword sets the mode; other lines are domain specs (globs, explicit domains, or the scope keywords PUBLIC / LOCAL / PRIVATE):\n\n- (no keyword) \u2014 interactive semantic search form\n- `SIMILAR: high|medium|low` \u2014 ambient: pages similar to this page\n- `REPORT` \u2014 server-side ranked, stub-filtered, fork-bundled report\n- `KEYWORD` \u2014 galactic MiniSearch over live site-index.json files\n- `LIST` \u2014 table of indexed domains\n- `AUTHOR` \u2014 search results as a draggable ghost page\n- `BUILD` / `FORCE` \u2014 proxy to the farm indexer, where configured\n- `GHOST url` / `BUTTON label` \u2014 open any page-json as a ghost page\n- `ROSTER site/slug` \u2014 add a roster page's sites to the scope (off-farm sites searched via the galaxy cache)\n- `FARM domain` \u2014 ask a peer farm to continue the search. The peer answers from exactly the sites whose own [[Federated Farm Search]] page grants your origin (`FROM` lines; `FROM *` needs the peer admin's `WIKI_PEER_FEDERATION=open`)\n- `THRESHOLD: 0.72` \u00b7 `LIMIT: 8` \u00b7 `LIVE` \u2014 tuning"
13
13
  },
14
14
  {
15
15
  "type": "markdown",
@@ -54,12 +54,12 @@
54
54
  {
55
55
  "type": "markdown",
56
56
  "id": "d9a96dfebb9d1692",
57
- "text": "## Server Routes\n\nAll same-origin, registered by the plugin's server component:\n\n- `GET /system/indexed-domains.json?pattern=`\n- `GET /system/semantic-vectors.json?domain=`\n- `GET /system/embed.json?text=`\n- `POST /system/search-report.json`\n- `GET /system/farm-search.json?q=&pattern=&limit=`\n- `GET /system/build-index.json?domains=` (proxied to the farm indexer)\n- `GET /system/galaxy-search.json?q=&sites=` keyword search over off-farm federation sites\n- `GET /system/peer-hello.json` capability probe: version, model, federation status (404 = plugin absent)\n- `POST /system/peer-search.json` answer another farm's search. Two keys: the admin's `WIKI_PEER_FEDERATION` ceiling (off / grants / open) and each site's Federated Farm Search grants page; scope = union of granting sites; hop limit, replay dedup, IP-keyed + global rate limits; restricted sites always excluded\n\nIndexes are `status/semantic-vectors.json` per domain built by the farm indexer, distributed by sync, read from farm disk. Keyword search reads each site's per-edit `status/site-index.json`; no separate index is built. Query embedding is BAAI bge-small-en-v1.5, 384-dim, CLS-pooled, unit-normalised byte-compatible with the index builder."
57
+ "text": "## Server Routes\n\nAll same-origin, registered by the plugin's server component:\n\n- `GET /system/indexed-domains.json?pattern=`\n- `GET /system/semantic-vectors.json?domain=`\n- `GET /system/embed.json?text=`\n- `POST /system/search-report.json`\n- `GET /system/farm-search.json?q=&pattern=&limit=`\n- `GET /system/build-index.json?domains=` (proxied to the farm indexer)\n- `GET /system/galaxy-search.json?q=&sites=` \u2014 keyword search over off-farm federation sites\n- `GET /system/peer-hello.json` \u2014 capability probe: version, model, federation status (404 = plugin absent)\n- `GET /system/similarity-health.json` \u2014 embedder supervisor state: child-process / semindex / url, breaker status, recent crashes\n- `POST /system/peer-search.json` \u2014 answer another farm's search. Two keys: the admin's `WIKI_PEER_FEDERATION` ceiling (off / grants / open) and each site's Federated Farm Search grants page; scope = union of granting sites; hop limit, replay dedup, IP-keyed + global rate limits; restricted sites always excluded\n\nIndexes are `status/semantic-vectors.json` per domain \u2014 built by the farm indexer, distributed by sync, read from farm disk. Keyword search reads each site's per-edit `status/site-index.json`; no separate index is built. Query embedding is BAAI bge-small-en-v1.5, 384-dim, CLS-pooled, unit-normalised \u2014 byte-compatible with the index builder.\n\nSince 0.9.0 all query embedding runs in a supervised child process, never in the wiki server itself: a crash in the ONNX runtime fails that one search, and repeated crashes trip a circuit breaker that answers 503 while cooling down. The worst case for a wiki running this plugin is \"semantic search temporarily unavailable\" \u2014 never a dead wiki."
58
58
  },
59
59
  {
60
60
  "type": "markdown",
61
61
  "id": "21eb5354d2adb29e",
62
- "text": "**Version:** 0.7.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] architecture, indexing, freshness."
62
+ "text": "**Version:** 0.9.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."
63
63
  }
64
64
  ],
65
65
  "plugin": "similarity",
@@ -72,12 +72,12 @@
72
72
  {
73
73
  "type": "markdown",
74
74
  "id": "aec1dfcd5f2bc42b",
75
- "text": "The Similarity plugin adds search to a Federated Wiki farm semantic similarity over prebuilt vector indexes, ranked search reports, and galactic keyword search over each site's own MiniSearch index. One item type **similarity** with a line-oriented DSL, plus a server component that serves every search same-origin. Works on any host: embedding runs in-process (native ONNX where available, WASM everywhere else)."
75
+ "text": "The Similarity plugin adds search to a Federated Wiki farm \u2014 semantic similarity over prebuilt vector indexes, ranked search reports, and galactic keyword search over each site's own MiniSearch index. One item type \u2014 **similarity** \u2014 with a line-oriented DSL, plus a server component that serves every search same-origin. Works on any host: embedding runs in-process (native ONNX where available, WASM everywhere else)."
76
76
  },
77
77
  {
78
78
  "type": "markdown",
79
79
  "id": "28edff984cc5334d",
80
- "text": "## The DSL\n\nThe first ALL-CAPS keyword sets the mode; other lines are domain specs (globs, explicit domains, or the scope keywords PUBLIC / LOCAL / PRIVATE):\n\n- (no keyword) interactive semantic search form\n- `SIMILAR: high|medium|low` ambient: pages similar to this page\n- `REPORT` server-side ranked, stub-filtered, fork-bundled report\n- `KEYWORD` galactic MiniSearch over live site-index.json files\n- `LIST` table of indexed domains\n- `AUTHOR` search results as a draggable ghost page\n- `BUILD` / `FORCE` proxy to the farm indexer, where configured\n- `GHOST url` / `BUTTON label` open any page-json as a ghost page\n- `THRESHOLD: 0.72` · `LIMIT: 8` · `LIVE` tuning"
80
+ "text": "## The DSL\n\nThe first ALL-CAPS keyword sets the mode; other lines are domain specs (globs, explicit domains, or the scope keywords PUBLIC / LOCAL / PRIVATE):\n\n- (no keyword) \u2014 interactive semantic search form\n- `SIMILAR: high|medium|low` \u2014 ambient: pages similar to this page\n- `REPORT` \u2014 server-side ranked, stub-filtered, fork-bundled report\n- `KEYWORD` \u2014 galactic MiniSearch over live site-index.json files\n- `LIST` \u2014 table of indexed domains\n- `AUTHOR` \u2014 search results as a draggable ghost page\n- `BUILD` / `FORCE` \u2014 proxy to the farm indexer, where configured\n- `GHOST url` / `BUTTON label` \u2014 open any page-json as a ghost page\n- `THRESHOLD: 0.72` \u00b7 `LIMIT: 8` \u00b7 `LIVE` \u2014 tuning"
81
81
  },
82
82
  {
83
83
  "type": "markdown",
@@ -122,17 +122,17 @@
122
122
  {
123
123
  "type": "markdown",
124
124
  "id": "d9a96dfebb9d1692",
125
- "text": "## Server Routes\n\nAll same-origin, registered by the plugin's server component:\n\n- `GET /system/indexed-domains.json?pattern=`\n- `GET /system/semantic-vectors.json?domain=`\n- `GET /system/embed.json?text=`\n- `POST /system/search-report.json`\n- `GET /system/farm-search.json?q=&pattern=&limit=`\n- `GET /system/build-index.json?domains=` (proxied to the farm indexer)\n\nIndexes are `status/semantic-vectors.json` per domain built by the farm indexer, distributed by sync, read from farm disk. Keyword search reads each site's per-edit `status/site-index.json`; no separate index is built. Query embedding is BAAI bge-small-en-v1.5, 384-dim, CLS-pooled, unit-normalised byte-compatible with the index builder."
125
+ "text": "## Server Routes\n\nAll same-origin, registered by the plugin's server component:\n\n- `GET /system/indexed-domains.json?pattern=`\n- `GET /system/semantic-vectors.json?domain=`\n- `GET /system/embed.json?text=`\n- `POST /system/search-report.json`\n- `GET /system/farm-search.json?q=&pattern=&limit=`\n- `GET /system/build-index.json?domains=` (proxied to the farm indexer)\n\nIndexes are `status/semantic-vectors.json` per domain \u2014 built by the farm indexer, distributed by sync, read from farm disk. Keyword search reads each site's per-edit `status/site-index.json`; no separate index is built. Query embedding is BAAI bge-small-en-v1.5, 384-dim, CLS-pooled, unit-normalised \u2014 byte-compatible with the index builder."
126
126
  },
127
127
  {
128
128
  "type": "markdown",
129
129
  "id": "21eb5354d2adb29e",
130
- "text": "**Version:** 0.5.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] architecture, indexing, freshness."
130
+ "text": "**Version:** 0.5.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."
131
131
  }
132
132
  ]
133
133
  },
134
134
  "date": 1783782734556,
135
- "provenance": "Authored by an AI agent Claude Fable 5 via the [[fedwiki-page]] skill. [https://claude.com/claude-code Claude Code]"
135
+ "provenance": "Authored by an AI agent \u2014 Claude Fable 5 via the [[fedwiki-page]] skill. [https://claude.com/claude-code Claude Code]"
136
136
  },
137
137
  {
138
138
  "type": "edit",
@@ -140,7 +140,7 @@
140
140
  "item": {
141
141
  "type": "markdown",
142
142
  "id": "28edff984cc5334d",
143
- "text": "## The DSL\n\nThe first ALL-CAPS keyword sets the mode; other lines are domain specs (globs, explicit domains, or the scope keywords PUBLIC / LOCAL / PRIVATE):\n\n- (no keyword) interactive semantic search form\n- `SIMILAR: high|medium|low` ambient: pages similar to this page\n- `REPORT` server-side ranked, stub-filtered, fork-bundled report\n- `KEYWORD` galactic MiniSearch over live site-index.json files\n- `LIST` table of indexed domains\n- `AUTHOR` search results as a draggable ghost page\n- `BUILD` / `FORCE` proxy to the farm indexer, where configured\n- `GHOST url` / `BUTTON label` open any page-json as a ghost page\n- `ROSTER site/slug` add a roster page's sites to the scope (off-farm sites searched via the galaxy cache)\n- `FARM domain` ask a peer farm to continue the search (experimental; requires the peer's `search-peers` roster to allow you)\n- `THRESHOLD: 0.72` · `LIMIT: 8` · `LIVE` tuning"
143
+ "text": "## The DSL\n\nThe first ALL-CAPS keyword sets the mode; other lines are domain specs (globs, explicit domains, or the scope keywords PUBLIC / LOCAL / PRIVATE):\n\n- (no keyword) \u2014 interactive semantic search form\n- `SIMILAR: high|medium|low` \u2014 ambient: pages similar to this page\n- `REPORT` \u2014 server-side ranked, stub-filtered, fork-bundled report\n- `KEYWORD` \u2014 galactic MiniSearch over live site-index.json files\n- `LIST` \u2014 table of indexed domains\n- `AUTHOR` \u2014 search results as a draggable ghost page\n- `BUILD` / `FORCE` \u2014 proxy to the farm indexer, where configured\n- `GHOST url` / `BUTTON label` \u2014 open any page-json as a ghost page\n- `ROSTER site/slug` \u2014 add a roster page's sites to the scope (off-farm sites searched via the galaxy cache)\n- `FARM domain` \u2014 ask a peer farm to continue the search (experimental; requires the peer's `search-peers` roster to allow you)\n- `THRESHOLD: 0.72` \u00b7 `LIMIT: 8` \u00b7 `LIVE` \u2014 tuning"
144
144
  },
145
145
  "date": 1783800718188
146
146
  },
@@ -150,7 +150,7 @@
150
150
  "item": {
151
151
  "type": "markdown",
152
152
  "id": "d9a96dfebb9d1692",
153
- "text": "## Server Routes\n\nAll same-origin, registered by the plugin's server component:\n\n- `GET /system/indexed-domains.json?pattern=`\n- `GET /system/semantic-vectors.json?domain=`\n- `GET /system/embed.json?text=`\n- `POST /system/search-report.json`\n- `GET /system/farm-search.json?q=&pattern=&limit=`\n- `GET /system/build-index.json?domains=` (proxied to the farm indexer)\n- `GET /system/galaxy-search.json?q=&sites=` keyword search over off-farm federation sites\n- `POST /system/peer-search.json` answer another farm's search (guarded: allowlist roster, hop limit, replay dedup, rate limit; restricted sites always excluded)\n\nIndexes are `status/semantic-vectors.json` per domain built by the farm indexer, distributed by sync, read from farm disk. Keyword search reads each site's per-edit `status/site-index.json`; no separate index is built. Query embedding is BAAI bge-small-en-v1.5, 384-dim, CLS-pooled, unit-normalised byte-compatible with the index builder."
153
+ "text": "## Server Routes\n\nAll same-origin, registered by the plugin's server component:\n\n- `GET /system/indexed-domains.json?pattern=`\n- `GET /system/semantic-vectors.json?domain=`\n- `GET /system/embed.json?text=`\n- `POST /system/search-report.json`\n- `GET /system/farm-search.json?q=&pattern=&limit=`\n- `GET /system/build-index.json?domains=` (proxied to the farm indexer)\n- `GET /system/galaxy-search.json?q=&sites=` \u2014 keyword search over off-farm federation sites\n- `POST /system/peer-search.json` \u2014 answer another farm's search (guarded: allowlist roster, hop limit, replay dedup, rate limit; restricted sites always excluded)\n\nIndexes are `status/semantic-vectors.json` per domain \u2014 built by the farm indexer, distributed by sync, read from farm disk. Keyword search reads each site's per-edit `status/site-index.json`; no separate index is built. Query embedding is BAAI bge-small-en-v1.5, 384-dim, CLS-pooled, unit-normalised \u2014 byte-compatible with the index builder."
154
154
  },
155
155
  "date": 1783800718188
156
156
  },
@@ -160,7 +160,7 @@
160
160
  "item": {
161
161
  "type": "markdown",
162
162
  "id": "21eb5354d2adb29e",
163
- "text": "**Version:** 0.6.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] architecture, indexing, freshness."
163
+ "text": "**Version:** 0.6.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."
164
164
  },
165
165
  "date": 1783800718188
166
166
  },
@@ -170,7 +170,7 @@
170
170
  "item": {
171
171
  "type": "markdown",
172
172
  "id": "28edff984cc5334d",
173
- "text": "## The DSL\n\nThe first ALL-CAPS keyword sets the mode; other lines are domain specs (globs, explicit domains, or the scope keywords PUBLIC / LOCAL / PRIVATE):\n\n- (no keyword) interactive semantic search form\n- `SIMILAR: high|medium|low` ambient: pages similar to this page\n- `REPORT` server-side ranked, stub-filtered, fork-bundled report\n- `KEYWORD` galactic MiniSearch over live site-index.json files\n- `LIST` table of indexed domains\n- `AUTHOR` search results as a draggable ghost page\n- `BUILD` / `FORCE` proxy to the farm indexer, where configured\n- `GHOST url` / `BUTTON label` open any page-json as a ghost page\n- `ROSTER site/slug` add a roster page's sites to the scope (off-farm sites searched via the galaxy cache)\n- `FARM domain` ask a peer farm to continue the search. The peer answers from exactly the sites whose own [[Federated Farm Search]] page grants your origin (`FROM` lines; `FROM *` needs the peer admin's `WIKI_PEER_FEDERATION=open`)\n- `THRESHOLD: 0.72` · `LIMIT: 8` · `LIVE` tuning"
173
+ "text": "## The DSL\n\nThe first ALL-CAPS keyword sets the mode; other lines are domain specs (globs, explicit domains, or the scope keywords PUBLIC / LOCAL / PRIVATE):\n\n- (no keyword) \u2014 interactive semantic search form\n- `SIMILAR: high|medium|low` \u2014 ambient: pages similar to this page\n- `REPORT` \u2014 server-side ranked, stub-filtered, fork-bundled report\n- `KEYWORD` \u2014 galactic MiniSearch over live site-index.json files\n- `LIST` \u2014 table of indexed domains\n- `AUTHOR` \u2014 search results as a draggable ghost page\n- `BUILD` / `FORCE` \u2014 proxy to the farm indexer, where configured\n- `GHOST url` / `BUTTON label` \u2014 open any page-json as a ghost page\n- `ROSTER site/slug` \u2014 add a roster page's sites to the scope (off-farm sites searched via the galaxy cache)\n- `FARM domain` \u2014 ask a peer farm to continue the search. The peer answers from exactly the sites whose own [[Federated Farm Search]] page grants your origin (`FROM` lines; `FROM *` needs the peer admin's `WIKI_PEER_FEDERATION=open`)\n- `THRESHOLD: 0.72` \u00b7 `LIMIT: 8` \u00b7 `LIVE` \u2014 tuning"
174
174
  },
175
175
  "date": 1784591495272
176
176
  },
@@ -180,7 +180,7 @@
180
180
  "item": {
181
181
  "type": "markdown",
182
182
  "id": "d9a96dfebb9d1692",
183
- "text": "## Server Routes\n\nAll same-origin, registered by the plugin's server component:\n\n- `GET /system/indexed-domains.json?pattern=`\n- `GET /system/semantic-vectors.json?domain=`\n- `GET /system/embed.json?text=`\n- `POST /system/search-report.json`\n- `GET /system/farm-search.json?q=&pattern=&limit=`\n- `GET /system/build-index.json?domains=` (proxied to the farm indexer)\n- `GET /system/galaxy-search.json?q=&sites=` keyword search over off-farm federation sites\n- `GET /system/peer-hello.json` capability probe: version, model, federation status (404 = plugin absent)\n- `POST /system/peer-search.json` answer another farm's search. Two keys: the admin's `WIKI_PEER_FEDERATION` ceiling (off / grants / open) and each site's Federated Farm Search grants page; scope = union of granting sites; hop limit, replay dedup, IP-keyed + global rate limits; restricted sites always excluded\n\nIndexes are `status/semantic-vectors.json` per domain built by the farm indexer, distributed by sync, read from farm disk. Keyword search reads each site's per-edit `status/site-index.json`; no separate index is built. Query embedding is BAAI bge-small-en-v1.5, 384-dim, CLS-pooled, unit-normalised byte-compatible with the index builder."
183
+ "text": "## Server Routes\n\nAll same-origin, registered by the plugin's server component:\n\n- `GET /system/indexed-domains.json?pattern=`\n- `GET /system/semantic-vectors.json?domain=`\n- `GET /system/embed.json?text=`\n- `POST /system/search-report.json`\n- `GET /system/farm-search.json?q=&pattern=&limit=`\n- `GET /system/build-index.json?domains=` (proxied to the farm indexer)\n- `GET /system/galaxy-search.json?q=&sites=` \u2014 keyword search over off-farm federation sites\n- `GET /system/peer-hello.json` \u2014 capability probe: version, model, federation status (404 = plugin absent)\n- `POST /system/peer-search.json` \u2014 answer another farm's search. Two keys: the admin's `WIKI_PEER_FEDERATION` ceiling (off / grants / open) and each site's Federated Farm Search grants page; scope = union of granting sites; hop limit, replay dedup, IP-keyed + global rate limits; restricted sites always excluded\n\nIndexes are `status/semantic-vectors.json` per domain \u2014 built by the farm indexer, distributed by sync, read from farm disk. Keyword search reads each site's per-edit `status/site-index.json`; no separate index is built. Query embedding is BAAI bge-small-en-v1.5, 384-dim, CLS-pooled, unit-normalised \u2014 byte-compatible with the index builder."
184
184
  },
185
185
  "date": 1784591495272
186
186
  },
@@ -190,9 +190,29 @@
190
190
  "item": {
191
191
  "type": "markdown",
192
192
  "id": "21eb5354d2adb29e",
193
- "text": "**Version:** 0.7.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] architecture, indexing, freshness."
193
+ "text": "**Version:** 0.7.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."
194
194
  },
195
195
  "date": 1784591495272
196
+ },
197
+ {
198
+ "type": "edit",
199
+ "id": "d9a96dfebb9d1692",
200
+ "item": {
201
+ "type": "markdown",
202
+ "id": "d9a96dfebb9d1692",
203
+ "text": "## Server Routes\n\nAll same-origin, registered by the plugin's server component:\n\n- `GET /system/indexed-domains.json?pattern=`\n- `GET /system/semantic-vectors.json?domain=`\n- `GET /system/embed.json?text=`\n- `POST /system/search-report.json`\n- `GET /system/farm-search.json?q=&pattern=&limit=`\n- `GET /system/build-index.json?domains=` (proxied to the farm indexer)\n- `GET /system/galaxy-search.json?q=&sites=` \u2014 keyword search over off-farm federation sites\n- `GET /system/peer-hello.json` \u2014 capability probe: version, model, federation status (404 = plugin absent)\n- `GET /system/similarity-health.json` \u2014 embedder supervisor state: child-process / semindex / url, breaker status, recent crashes\n- `POST /system/peer-search.json` \u2014 answer another farm's search. Two keys: the admin's `WIKI_PEER_FEDERATION` ceiling (off / grants / open) and each site's Federated Farm Search grants page; scope = union of granting sites; hop limit, replay dedup, IP-keyed + global rate limits; restricted sites always excluded\n\nIndexes are `status/semantic-vectors.json` per domain \u2014 built by the farm indexer, distributed by sync, read from farm disk. Keyword search reads each site's per-edit `status/site-index.json`; no separate index is built. Query embedding is BAAI bge-small-en-v1.5, 384-dim, CLS-pooled, unit-normalised \u2014 byte-compatible with the index builder.\n\nSince 0.9.0 all query embedding runs in a supervised child process, never in the wiki server itself: a crash in the ONNX runtime fails that one search, and repeated crashes trip a circuit breaker that answers 503 while cooling down. The worst case for a wiki running this plugin is \"semantic search temporarily unavailable\" \u2014 never a dead wiki."
204
+ },
205
+ "date": 1784706029671
206
+ },
207
+ {
208
+ "type": "edit",
209
+ "id": "21eb5354d2adb29e",
210
+ "item": {
211
+ "type": "markdown",
212
+ "id": "21eb5354d2adb29e",
213
+ "text": "**Version:** 0.9.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."
214
+ },
215
+ "date": 1784706029671
196
216
  }
197
217
  ]
198
218
  }
@@ -0,0 +1,120 @@
1
+ // wiki-plugin-similarity — embedding child process (CommonJS)
2
+ //
3
+ // ALL onnx/transformers work happens here, in a process fork()ed by
4
+ // embedder.js — never in the wiki-server process. A fatal error in the WASM
5
+ // runtime (abort, OOM, bad model file) kills this child only; the parent
6
+ // sees an exit event, fails the in-flight requests cleanly, and the farm
7
+ // keeps serving pages. That isolation is the plugin's safety contract:
8
+ // worst case is "semantic search unavailable", never a dead wiki.
9
+ //
10
+ // IPC protocol (parent → child, child → parent):
11
+ // {id, op: 'embed', text} → {id, ok: true, vector: number[384]}
12
+ // {id, op: 'warm'} → {id, ok: true}
13
+ // any failure → {id, ok: false, error}
14
+ //
15
+ // The child exits 0 after WIKI_EMBED_IDLE_MS without work (default 5 min)
16
+ // so the ~0.5GB model doesn't stay pinned between searches; the parent
17
+ // respawns on demand and doesn't count a clean exit as a crash.
18
+ //
19
+ // WIKI_EMBED_MOCK=1 skips the model entirely and returns deterministic
20
+ // vectors — for tests and CI, where a text of '__CRASH__' also simulates a
21
+ // hard crash so the parent's isolation and breaker can be exercised.
22
+
23
+ const MODEL = process.env.WIKI_EMBED_MODEL || 'Xenova/bge-small-en-v1.5'
24
+ const POOLING = process.env.WIKI_EMBED_POOLING || 'cls'
25
+ const QUANTIZED = process.env.WIKI_EMBED_QUANTIZED === '1'
26
+ const IDLE_MS = parseInt(process.env.WIKI_EMBED_IDLE_MS) || 5 * 60_000
27
+ const MOCK = process.env.WIKI_EMBED_MOCK === '1'
28
+
29
+ // transformers.js statically imports onnxruntime-node, whose native binary
30
+ // needs glibc. On musl containers (node:alpine — the public farm) that import
31
+ // throws "Error loading shared library ld-linux-x86-64.so.2". Probe the
32
+ // native runtime with a CJS require first; if it can't load, register a
33
+ // module hook (Node >= 18.19) that redirects the onnxruntime-node import to
34
+ // onnxruntime-web — the WASM backend runs everywhere and benchmarks ~25ms a
35
+ // query. WIKI_EMBED_FORCE_WASM=1 forces the WASM path for testing.
36
+
37
+ const nativeOrtLoads = () => {
38
+ if (process.env.WIKI_EMBED_FORCE_WASM === '1') return false
39
+ try { require('onnxruntime-node'); return true } catch { return false }
40
+ }
41
+
42
+ let extractorPromise = null
43
+
44
+ const getExtractor = () => {
45
+ if (!extractorPromise) {
46
+ extractorPromise = (async () => {
47
+ if (!nativeOrtLoads()) {
48
+ const { register } = require('node:module')
49
+ if (typeof register !== 'function') {
50
+ throw new Error('native onnxruntime unavailable and Node lacks ' +
51
+ 'module.register (need >= 18.19) for the WASM fallback')
52
+ }
53
+ register('./ort-hooks.mjs', require('node:url').pathToFileURL(__filename))
54
+ console.log('[wiki-plugin-similarity] embed child: native onnxruntime unavailable — using WASM backend')
55
+ }
56
+ // Dynamic import: @xenova/transformers is ESM-only; this file must stay CJS.
57
+ // The heavy deps are optionalDependencies — absent on slim installs,
58
+ // where semindex delegation or WIKI_EMBED_URL is the path.
59
+ const { pipeline, env } = await import('@xenova/transformers').catch(() => {
60
+ throw new Error('no local embedder: @xenova/transformers is not installed. ' +
61
+ 'Install wiki-plugin-semindex, set WIKI_EMBED_URL, or reinstall ' +
62
+ 'wiki-plugin-similarity with optional dependencies.')
63
+ })
64
+ if (process.env.WIKI_MODEL_CACHE) env.cacheDir = process.env.WIKI_MODEL_CACHE
65
+ if (env.backends?.onnx?.wasm) env.backends.onnx.wasm.numThreads = 1
66
+ return pipeline('feature-extraction', MODEL, { quantized: QUANTIZED })
67
+ })()
68
+ extractorPromise.catch(() => { extractorPromise = null }) // allow retry
69
+ }
70
+ return extractorPromise
71
+ }
72
+
73
+ // Deterministic unit vector from text — mock mode only.
74
+ const mockVector = text => {
75
+ const crypto = require('node:crypto')
76
+ const v = []
77
+ let seed = crypto.createHash('sha256').update(text).digest()
78
+ while (v.length < 384) {
79
+ for (const byte of seed) v.push((byte - 127.5) / 127.5)
80
+ seed = crypto.createHash('sha256').update(seed).digest()
81
+ }
82
+ const vec = v.slice(0, 384)
83
+ const norm = Math.sqrt(vec.reduce((s, x) => s + x * x, 0))
84
+ return vec.map(x => x / norm)
85
+ }
86
+
87
+ const embed = async text => {
88
+ if (MOCK) {
89
+ if (text === '__CRASH__') process.exit(13) // simulated hard crash (tests)
90
+ return mockVector(text)
91
+ }
92
+ const extractor = await getExtractor()
93
+ const out = await extractor(text, { pooling: POOLING, normalize: true })
94
+ return Array.from(out.data)
95
+ }
96
+
97
+ let idleTimer = null
98
+ const bumpIdle = () => {
99
+ clearTimeout(idleTimer)
100
+ idleTimer = setTimeout(() => process.exit(0), IDLE_MS)
101
+ }
102
+
103
+ process.on('message', async msg => {
104
+ bumpIdle()
105
+ try {
106
+ if (msg.op === 'warm') {
107
+ if (!MOCK) await getExtractor()
108
+ process.send({ id: msg.id, ok: true })
109
+ } else {
110
+ process.send({ id: msg.id, ok: true, vector: await embed(msg.text) })
111
+ }
112
+ } catch (e) {
113
+ try { process.send({ id: msg.id, ok: false, error: e.message }) } catch { /* parent gone */ }
114
+ }
115
+ })
116
+
117
+ // Parent died or closed the channel — no reason to linger.
118
+ process.on('disconnect', () => process.exit(0))
119
+
120
+ bumpIdle()
@@ -1,32 +1,42 @@
1
- // wiki-plugin-similarity — in-process query embedder (CommonJS)
1
+ // wiki-plugin-similarity — query embedder, child-process supervisor (CommonJS)
2
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.
3
+ // Since 0.9.0 the wiki-server process NEVER touches onnx/transformers: all
4
+ // inference runs in a fork()ed embed-child.js whose crash is an event here,
5
+ // not a farm outage. (0.8.x ran the WASM runtime in-process; a fatal error
6
+ // there took down every site the farm served — see the 2026-07-22
7
+ // hitchhikers.earth incident.) This module supervises the child:
7
8
  //
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().
9
+ // - lazy fork on first use, V8 heap capped via --max-old-space-size
10
+ // (WIKI_EMBED_CHILD_HEAP_MB, default 512)
11
+ // - per-request timeout (WIKI_EMBED_TIMEOUT_MS, default 60s first call
12
+ // includes model load; the one-time ~130MB download can exceed this,
13
+ // in which case warm() retries make it eventually succeed)
14
+ // - circuit breaker: WIKI_EMBED_MAX_CRASHES abnormal exits (default 3)
15
+ // within WIKI_EMBED_CRASH_WINDOW_MS (default 10 min) opens the breaker
16
+ // for WIKI_EMBED_COOLDOWN_MS (default 5 min); while open, embed()
17
+ // rejects immediately with err.code = 'EMBEDDER_DOWN' so routes can
18
+ // answer 503 instead of respawning a crash-loop
19
+ // - the child's clean idle self-exit (code 0) is not a crash
14
20
  //
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.
21
+ // Embedding parity knobs (WIKI_EMBED_MODEL / _POOLING / _QUANTIZED) are read
22
+ // by the child from the inherited env — see embed-child.js for why they
23
+ // exist and their defaults (bge-small-en-v1.5, cls, fp32 — must match the
24
+ // prebuilt semantic-vectors.json).
25
+ //
26
+ // wiki-plugin-semindex's shared worker-thread embedder is still preferred
27
+ // when installed: one model instance per process, shared with bulk indexing.
28
+ // The sibling path works for npm installs (wiki/node_modules/*) and dev
29
+ // symlinks (~/Code/wiki-plugins/*) alike; the bare require covers other
30
+ // layouts.
17
31
 
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'
32
+ const path = require('node:path')
33
+ const fs = require('node:fs')
34
+ const { fork } = require('node:child_process')
21
35
 
22
- // Prefer wiki-plugin-semindex's shared worker-thread embedder when installed:
23
- // one model instance per process (shared with bulk indexing), inference off
24
- // the event loop, and similarity's own heavy deps become optional. The
25
- // sibling path works for npm installs (wiki/node_modules/*) and dev symlinks
26
- // (~/Code/wiki-plugins/*) alike; the bare require covers other layouts.
36
+ // WIKI_EMBED_NO_SEMINDEX=1 forces the child-process path even when semindex
37
+ // is installed for tests, and for opting a farm out of delegation.
27
38
  const semindexLib = (() => {
28
- const path = require('node:path')
29
- const fs = require('node:fs')
39
+ if (process.env.WIKI_EMBED_NO_SEMINDEX === '1') return null
30
40
  try {
31
41
  const sibling = path.resolve(__dirname, '../../wiki-plugin-semindex/server/embed-lib.js')
32
42
  if (fs.existsSync(sibling)) return require(sibling)
@@ -34,61 +44,117 @@ const semindexLib = (() => {
34
44
  } catch { return null }
35
45
  })()
36
46
 
37
- // transformers.js statically imports onnxruntime-node, whose native binary
38
- // needs glibc. On musl containers (node:alpine — the public farm) that import
39
- // throws "Error loading shared library ld-linux-x86-64.so.2". Probe the
40
- // native runtime with a CJS require first; if it can't load, register a
41
- // module hook (Node >= 18.19) that redirects the onnxruntime-node import to
42
- // onnxruntime-web — the WASM backend runs everywhere and benchmarks ~25ms a
43
- // query. WIKI_EMBED_FORCE_WASM=1 forces the WASM path for testing.
47
+ const HEAP_MB = parseInt(process.env.WIKI_EMBED_CHILD_HEAP_MB) || 512
48
+ const TIMEOUT_MS = parseInt(process.env.WIKI_EMBED_TIMEOUT_MS) || 60_000
49
+ const MAX_CRASHES = parseInt(process.env.WIKI_EMBED_MAX_CRASHES) || 3
50
+ const CRASH_WINDOW_MS = parseInt(process.env.WIKI_EMBED_CRASH_WINDOW_MS) || 10 * 60_000
51
+ const COOLDOWN_MS = parseInt(process.env.WIKI_EMBED_COOLDOWN_MS) || 5 * 60_000
44
52
 
45
- const nativeOrtLoads = () => {
46
- if (process.env.WIKI_EMBED_FORCE_WASM === '1') return false
47
- try { require('onnxruntime-node'); return true } catch { return false }
53
+ const embedderError = (message, code) => {
54
+ const e = new Error(message)
55
+ if (code) e.code = code
56
+ return e
48
57
  }
49
58
 
50
- let extractorPromise = null
59
+ let child = null
60
+ let nextId = 1
61
+ const inflight = new Map() // id → {resolve, reject, timer}
62
+
63
+ let crashTimes = []
64
+ let breakerOpenUntil = 0
65
+ let lastError = null
66
+
67
+ const breakerOpen = () => Date.now() < breakerOpenUntil
68
+
69
+ const ensureChild = () => {
70
+ if (child) return child
71
+ child = fork(path.join(__dirname, 'embed-child.js'), [], {
72
+ execArgv: [`--max-old-space-size=${HEAP_MB}`],
73
+ })
74
+ child.on('message', msg => {
75
+ const pending = inflight.get(msg.id)
76
+ if (!pending) return
77
+ inflight.delete(msg.id)
78
+ clearTimeout(pending.timer)
79
+ if (msg.ok) pending.resolve(msg)
80
+ else pending.reject(embedderError(msg.error))
81
+ })
82
+ child.on('error', e => { lastError = `fork failed: ${e.message}` })
83
+ child.on('exit', (code, signal) => {
84
+ child = null
85
+ const abnormal = code !== 0
86
+ const why = signal ? `signal ${signal}` : `code ${code}`
87
+ for (const pending of inflight.values()) {
88
+ clearTimeout(pending.timer)
89
+ pending.reject(embedderError(`embedder process exited (${why})`))
90
+ }
91
+ inflight.clear()
92
+ if (!abnormal) return
93
+ lastError = `embed child exited: ${why}`
94
+ console.error(`[wiki-plugin-similarity] ${lastError}`)
95
+ const now = Date.now()
96
+ crashTimes = crashTimes.filter(t => now - t < CRASH_WINDOW_MS)
97
+ crashTimes.push(now)
98
+ if (crashTimes.length >= MAX_CRASHES) {
99
+ breakerOpenUntil = now + COOLDOWN_MS
100
+ console.error(`[wiki-plugin-similarity] ${crashTimes.length} embedder crashes in ` +
101
+ `${Math.round(CRASH_WINDOW_MS / 60_000)} min — semantic search disabled for ` +
102
+ `${Math.round(COOLDOWN_MS / 60_000)} min`)
103
+ }
104
+ })
105
+ return child
106
+ }
51
107
 
52
- const getExtractor = () => {
53
- if (!extractorPromise) {
54
- extractorPromise = (async () => {
55
- if (!nativeOrtLoads()) {
56
- const { register } = require('node:module')
57
- if (typeof register !== 'function') {
58
- throw new Error('native onnxruntime unavailable and Node lacks ' +
59
- 'module.register (need >= 18.19) for the WASM fallback')
60
- }
61
- register('./ort-hooks.mjs', require('node:url').pathToFileURL(__filename))
62
- console.log('[wiki-plugin-similarity] native onnxruntime unavailable — using WASM backend')
63
- }
64
- // Dynamic import: @xenova/transformers is ESM-only; this file must stay CJS.
65
- // Since 0.8.0 the heavy deps are optionalDependencies — absent on slim
66
- // installs, where semindex delegation or WIKI_EMBED_URL is the path.
67
- const { pipeline, env } = await import('@xenova/transformers').catch(() => {
68
- throw new Error('no local embedder: @xenova/transformers is not installed. ' +
69
- 'Install wiki-plugin-semindex, set WIKI_EMBED_URL, or reinstall ' +
70
- 'wiki-plugin-similarity with optional dependencies.')
71
- })
72
- if (process.env.WIKI_MODEL_CACHE) env.cacheDir = process.env.WIKI_MODEL_CACHE
73
- if (env.backends?.onnx?.wasm) env.backends.onnx.wasm.numThreads = 1
74
- return pipeline('feature-extraction', MODEL, { quantized: QUANTIZED })
75
- })()
76
- extractorPromise.catch(() => { extractorPromise = null }) // allow retry
108
+ const request = (op, text) => {
109
+ if (breakerOpen()) {
110
+ return Promise.reject(embedderError(
111
+ 'semantic search temporarily unavailable (embedder crashed repeatedly, cooling down)',
112
+ 'EMBEDDER_DOWN'))
77
113
  }
78
- return extractorPromise
114
+ return new Promise((resolve, reject) => {
115
+ const id = nextId++
116
+ const timer = setTimeout(() => {
117
+ inflight.delete(id)
118
+ reject(embedderError(`embed timed out after ${TIMEOUT_MS}ms`))
119
+ }, TIMEOUT_MS)
120
+ inflight.set(id, { resolve, reject, timer })
121
+ try {
122
+ ensureChild().send({ id, op, text })
123
+ } catch (e) {
124
+ inflight.delete(id)
125
+ clearTimeout(timer)
126
+ reject(embedderError(`embedder unavailable: ${e.message}`))
127
+ }
128
+ })
79
129
  }
80
130
 
81
131
  // Embed one text → plain number[384], unit-normalised.
82
132
  const embed = async text => {
83
133
  if (semindexLib) return semindexLib.embed(text)
84
- const extractor = await getExtractor()
85
- const out = await extractor(text, { pooling: POOLING, normalize: true })
86
- return Array.from(out.data)
134
+ const { vector } = await request('embed', text)
135
+ return vector
87
136
  }
88
137
 
89
- // Warm the model in the background (call at startup; failures just defer to
90
- // first request). With semindex delegation, don't warm — its worker spawns on
91
- // demand and self-terminates when idle; pre-warming would just pin ~0.5GB.
92
- const warm = () => { if (!semindexLib) getExtractor().catch(() => {}) }
138
+ // Warm the model in the background (call at startup; failures just log
139
+ // they can no longer crash the server). With semindex delegation, don't
140
+ // warm — its worker spawns on demand and self-terminates when idle;
141
+ // pre-warming would just pin ~0.5GB.
142
+ const warm = () => {
143
+ if (semindexLib) return
144
+ request('warm').catch(e =>
145
+ console.error('[wiki-plugin-similarity] embedder warm failed:', e.message))
146
+ }
147
+
148
+ // Supervisor state for /system/similarity-health.json.
149
+ const status = () => {
150
+ if (semindexLib) return { via: 'semindex' }
151
+ return {
152
+ via: 'child-process',
153
+ state: breakerOpen() ? 'down' : child ? 'running' : 'idle',
154
+ recentCrashes: crashTimes.filter(t => Date.now() - t < CRASH_WINDOW_MS).length,
155
+ breakerOpenUntil: breakerOpen() ? new Date(breakerOpenUntil).toISOString() : null,
156
+ lastError,
157
+ }
158
+ }
93
159
 
94
- module.exports = { embed, warm, viaSemindex: !!semindexLib }
160
+ module.exports = { embed, warm, status, viaSemindex: !!semindexLib }
package/server/server.js CHANGED
@@ -11,9 +11,12 @@
11
11
  // GET /system/semantic-vectors.json[?domain=]
12
12
  // Serves {farm}/{domain}/status/semantic-vectors.json.
13
13
  // GET /system/embed.json?text=…
14
- // 384-dim unit vector via the in-process transformers.js embedder
14
+ // 384-dim unit vector via the crash-isolated child-process embedder
15
15
  // (BAAI/bge-small-en-v1.5 — same model the indexes were built with).
16
16
  // Set WIKI_EMBED_URL to proxy to an external embedder instead.
17
+ // GET /system/similarity-health.json
18
+ // Embedder supervisor state (child-process | semindex | url, breaker,
19
+ // recent crashes) — 200 always; diagnosable from outside.
17
20
  // POST /system/search-report.json {query, domains, limit, threshold, live}
18
21
  // Ranked, stub-filtered, fork-bundled semantic report (page JSON).
19
22
  // GET /system/farm-search.json?q=…&pattern=…&limit=…
@@ -216,7 +219,7 @@ const startServer = ({ argv, app }) => {
216
219
  '/system/embed.json', '/system/search-report.json',
217
220
  '/system/farm-search.json', '/system/build-index.json',
218
221
  '/system/galaxy-search.json', '/system/peer-search.json',
219
- '/system/peer-hello.json']) {
222
+ '/system/peer-hello.json', '/system/similarity-health.json']) {
220
223
  app.options(route, (req, res) => { cors(res); res.sendStatus(204) })
221
224
  }
222
225
 
@@ -338,10 +341,22 @@ const startServer = ({ argv, app }) => {
338
341
  res.json({ vector: await embedText(text) })
339
342
  } catch (e) {
340
343
  console.error('[wiki-plugin-similarity] embed error:', e.message)
341
- res.status(502).json({ error: `embedding unavailable: ${e.message}` })
344
+ res.status(e.code === 'EMBEDDER_DOWN' ? 503 : 502)
345
+ .json({ error: `embedding unavailable: ${e.message}` })
342
346
  }
343
347
  })
344
348
 
349
+ // ── GET /system/similarity-health.json — embedder supervisor state ────────
350
+ app.get('/system/similarity-health.json', (req, res) => {
351
+ cors(res)
352
+ res.json({
353
+ plugin: 'wiki-plugin-similarity',
354
+ version: PLUGIN_VERSION,
355
+ ...MODEL_META,
356
+ embedder: EMBED_URL ? { via: 'url', url: EMBED_URL } : embedder.status(),
357
+ })
358
+ })
359
+
345
360
  // ── POST /system/search-report.json ────────────────────────────────────────
346
361
  // body.farms: peer farms asked to continue the search (FARM prototype).
347
362
  app.post('/system/search-report.json', async (req, res) => {
@@ -363,7 +378,8 @@ const startServer = ({ argv, app }) => {
363
378
  res.json(page)
364
379
  } catch (e) {
365
380
  console.error('[wiki-plugin-similarity] search-report error:', e.message)
366
- res.status(500).json({ error: `search-report failed: ${e.message}` })
381
+ res.status(e.code === 'EMBEDDER_DOWN' ? 503 : 500)
382
+ .json({ error: `search-report failed: ${e.message}` })
367
383
  }
368
384
  })
369
385
 
@@ -496,7 +512,8 @@ const startServer = ({ argv, app }) => {
496
512
  farm: req.hostname, sites: grantedNames.length, count } })
497
513
  } catch (e) {
498
514
  console.error('[wiki-plugin-similarity] peer-search error:', e.message)
499
- res.status(500).json({ error: `peer-search failed: ${e.message}` })
515
+ res.status(e.code === 'EMBEDDER_DOWN' ? 503 : 500)
516
+ .json({ error: `peer-search failed: ${e.message}` })
500
517
  }
501
518
  })
502
519