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.
- package/client/similarity.js +1 -1
- package/package.json +1 -1
- package/pages/about-similarity-plugin +35 -15
- package/server/embed-child.js +120 -0
- package/server/embedder.js +134 -68
- package/server/server.js +22 -5
package/client/similarity.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* wiki-plugin-similarity - 0.
|
|
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
|
@@ -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
|
|
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)
|
|
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=`
|
|
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.
|
|
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
|
|
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)
|
|
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
|
|
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]
|
|
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
|
|
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)
|
|
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=`
|
|
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]
|
|
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)
|
|
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=`
|
|
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]
|
|
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()
|
package/server/embedder.js
CHANGED
|
@@ -1,32 +1,42 @@
|
|
|
1
|
-
// wiki-plugin-similarity —
|
|
1
|
+
// wiki-plugin-similarity — query embedder, child-process supervisor (CommonJS)
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// -
|
|
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
|
-
//
|
|
16
|
-
//
|
|
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
|
|
19
|
-
const
|
|
20
|
-
const
|
|
32
|
+
const path = require('node:path')
|
|
33
|
+
const fs = require('node:fs')
|
|
34
|
+
const { fork } = require('node:child_process')
|
|
21
35
|
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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
|
|
85
|
-
|
|
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
|
|
90
|
-
//
|
|
91
|
-
// demand and self-terminates when idle;
|
|
92
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
515
|
+
res.status(e.code === 'EMBEDDER_DOWN' ? 503 : 500)
|
|
516
|
+
.json({ error: `peer-search failed: ${e.message}` })
|
|
500
517
|
}
|
|
501
518
|
})
|
|
502
519
|
|