wiki-plugin-similarity 0.4.4 → 0.5.1

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.
@@ -3,70 +3,66 @@
3
3
  "story": [
4
4
  {
5
5
  "type": "markdown",
6
- "id": "61ccb2d2cdb60c71",
7
- "text": "The Similarity plugin adds semantic similarity search to a Federated Wiki farm. One item type — **similarity** — with behaviour controlled by a DSL: interactive search, ambient similar-pages panel, domain listing, ranked search reports, ghost-page authoring, index building, and generic report buttons."
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)."
8
8
  },
9
9
  {
10
10
  "type": "markdown",
11
- "id": "a3f7c8d2e1b94056",
12
- "text": "**Version:** 0.4.3 [GitHub](https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-similarity) · [npm](https://www.npmjs.com/package/wiki-plugin-similarity)"
13
- },
14
- {
15
- "type": "similarity",
16
- "id": "ad3c41667c285efd",
17
- "text": "SIMILAR: medium\nLIMIT: 5"
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- `THRESHOLD: 0.72` · `LIMIT: 8` · `LIVE` — tuning"
18
13
  },
19
14
  {
20
15
  "type": "markdown",
21
- "id": "bdf93e3860d64ebb",
22
- "text": "## The DSL"
16
+ "id": "251793ca0dae4810",
17
+ "text": "## Try It\n\nSemantic search across the farm:"
23
18
  },
24
19
  {
25
- "type": "markdown",
26
- "id": "0d628d43edf9f8e8",
27
- "text": "The item's `text` field configures what the item does. Mode commands come first; the rest are options and domain specs:\n\n```\nSIMILAR ambient mode — similar pages to this one\nSIMILAR high threshold preset (high=0.78 / medium=0.68 / low=0.58)\nLIST table of indexed domains with page counts\nAUTHOR search → ghost page of [[wiki links]] + references\nREPORT server-side ranked report (stubs dropped, forks bundled)\nBUILD button that builds semantic indexes\nGHOST <url> button that opens any page-json URL as a ghost page\n\nTHRESHOLD 0.72 exact cosine threshold\nLIMIT 8 max results (default 10)\nLIVE bypass the localStorage cache\nFORCE BUILD: re-embed even when the index is fresh\nBUTTON <text> custom button caption (BUILD / GHOST)\n```\n\nColons and extra whitespace are optional — `LIMIT: 8`, `LIMIT 8`, and `LIMIT:8` all work. Leave `text` empty for an interactive search form on the current domain."
20
+ "type": "similarity",
21
+ "id": "86aeab8be16dd8e1",
22
+ "text": "*"
28
23
  },
29
24
  {
30
25
  "type": "markdown",
31
- "id": "1e2f3a4b5c6d7e8f",
32
- "text": "## Domain Specs and Scopes"
26
+ "id": "b6a9be0145bae9bd",
27
+ "text": "Pages similar to this one:"
33
28
  },
34
29
  {
35
- "type": "markdown",
36
- "id": "2f3a4b5c6d7e8f90",
37
- "text": "Any other line is a domain spec. Scope keywords expand server-side:\n\n```\n* all indexed domains, all farms\nPUBLIC the Nextcloud mirror farm\nLOCAL the localhost farm\nPRIVATE public wikis with restricted: true in the farm config\ndavid.* glob pattern\ndavid.hitchhikers.earth explicit domain\n# comment ignored\n```\n\nSpecs combine — `LOCAL` and `david.*` on separate lines searches both."
30
+ "type": "similarity",
31
+ "id": "cf558a16395d7d8c",
32
+ "text": "SIMILAR: medium\nLIMIT: 5"
38
33
  },
39
34
  {
40
35
  "type": "markdown",
41
- "id": "c0775c94a895e9d2",
42
- "text": "## Modes"
36
+ "id": "1aceb7035c7537b2",
37
+ "text": "A ranked report (opens as a ghost page):"
43
38
  },
44
39
  {
45
- "type": "markdown",
46
- "id": "4f91a3b2d8e05c11",
47
- "text": "**Search** (default) — interactive form; results appear inline as scored links. Shift-click a result to append it at the end of the lineup instead of truncating.\n\n**Similar** — runs on page load using the current page's pre-computed vector (no embed call if already indexed). Ranked *Similar Pages* list.\n\n**List** — table of indexed domains with page counts; supports scopes, globs, and `LIMIT`.\n\n**Author** — same search, but opens a ghost page with `# Similar Pages` ([[wiki links]], deduplicated) and `# Reference Links` (all results as reference items with scores).\n\n**Report** — posts to the farm API's `/search-report`: candidates from semantic scan ∪ title matching, stubs dropped, forks and near-duplicates bundled under the strongest copy with a one-click lineup link, ranked by semantic × richness × recency + title boost. Opens as a ghost page. *(localhost only — needs the FastAPI service.)*\n\n**Build** — a button that triggers semantic index building via `/build-index.json`; the build report opens as a ghost page. Add `FORCE` to re-embed fresh domains. *(localhost only.)*\n\n**Ghost** — a generic button: `GHOST <url>` fetches any endpoint returning fedwiki page JSON and opens it as a ghost page. Caption set with `BUTTON <text>`. Example: the *Farm Roster Report* button fetches `/farm-rosters.json`, which rosters every farm domain by scope with per-section error rosters for unserved domains."
40
+ "type": "similarity",
41
+ "id": "59bfe1e3968743a1",
42
+ "text": "REPORT\nLIMIT: 5"
48
43
  },
49
44
  {
50
45
  "type": "markdown",
51
- "id": "c0775c94a895e9d3",
52
- "text": "## Caching"
46
+ "id": "31b81a31f675c4e1",
47
+ "text": "Keyword search over the sites' own indexes:"
53
48
  },
54
49
  {
55
- "type": "markdown",
56
- "id": "9a1b2c3d4e5f6001",
57
- "text": "Results are cached in `localStorage` keyed by item id and invalidated whenever the item's `text` changes. Add `LIVE` anywhere in the DSL to bypass the cache."
50
+ "type": "similarity",
51
+ "id": "0c3fb9298ba8fd23",
52
+ "text": "KEYWORD\nLIMIT: 5"
58
53
  },
59
54
  {
60
55
  "type": "markdown",
61
- "id": "c0775c94a895e9d4",
62
- "text": "## How It Works"
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\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."
63
58
  },
64
59
  {
65
60
  "type": "markdown",
66
- "id": "4b1bbe95544548eb",
67
- "text": "Pages are pre-indexed as 384-dimensional embedding vectors using BAAI/bge-small-en-v1.5 via fastembed. The plugin loads `semantic-vectors.json` index files directly in the browser and scores them with cosine similarity in JavaScript — no remote search engine required.\n\nThe server component registers `/system/indexed-domains.json`, `/system/semantic-vectors.json`, and `/system/embed.json` routes, and understands the scope keywords. See [[Similarity Indexing]] for building indices and [[Build Semantic Index]] for triggering builds from the wiki."
61
+ "id": "21eb5354d2adb29e",
62
+ "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."
68
63
  }
69
64
  ],
65
+ "plugin": "similarity",
70
66
  "journal": [
71
67
  {
72
68
  "type": "create",
@@ -75,14 +71,68 @@
75
71
  "story": [
76
72
  {
77
73
  "type": "markdown",
78
- "id": "61ccb2d2cdb60c71",
79
- "text": "The Similarity plugin adds semantic similarity search to a Federated Wiki farm."
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)."
76
+ },
77
+ {
78
+ "type": "markdown",
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"
81
+ },
82
+ {
83
+ "type": "markdown",
84
+ "id": "251793ca0dae4810",
85
+ "text": "## Try It\n\nSemantic search across the farm:"
86
+ },
87
+ {
88
+ "type": "similarity",
89
+ "id": "86aeab8be16dd8e1",
90
+ "text": "*"
91
+ },
92
+ {
93
+ "type": "markdown",
94
+ "id": "b6a9be0145bae9bd",
95
+ "text": "Pages similar to this one:"
96
+ },
97
+ {
98
+ "type": "similarity",
99
+ "id": "cf558a16395d7d8c",
100
+ "text": "SIMILAR: medium\nLIMIT: 5"
101
+ },
102
+ {
103
+ "type": "markdown",
104
+ "id": "1aceb7035c7537b2",
105
+ "text": "A ranked report (opens as a ghost page):"
106
+ },
107
+ {
108
+ "type": "similarity",
109
+ "id": "59bfe1e3968743a1",
110
+ "text": "REPORT\nLIMIT: 5"
111
+ },
112
+ {
113
+ "type": "markdown",
114
+ "id": "31b81a31f675c4e1",
115
+ "text": "Keyword search over the sites' own indexes:"
116
+ },
117
+ {
118
+ "type": "similarity",
119
+ "id": "0c3fb9298ba8fd23",
120
+ "text": "KEYWORD\nLIMIT: 5"
121
+ },
122
+ {
123
+ "type": "markdown",
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."
126
+ },
127
+ {
128
+ "type": "markdown",
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."
80
131
  }
81
132
  ]
82
133
  },
83
- "date": 1781133629000,
84
- "certificate": "from marvin"
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]"
85
136
  }
86
- ],
87
- "plugin": "similarity"
88
- }
137
+ ]
138
+ }
@@ -0,0 +1,71 @@
1
+ // wiki-plugin-similarity — in-process query embedder (CommonJS)
2
+ //
3
+ // Reproduces the farm indexer's embeddings (fastembed, BAAI/bge-small-en-v1.5,
4
+ // 384-dim, L2-normalised, NO query prefix) using transformers.js — pure
5
+ // JS/WASM ONNX, no native dependencies, so it runs inside any wiki-server
6
+ // install including the public farm's container.
7
+ //
8
+ // Parity notes (verified against the FastAPI /embed reference):
9
+ // - pooling and quantisation are configurable via env because they are the
10
+ // two knobs that can desync scores from the prebuilt semantic-vectors.json:
11
+ // WIKI_EMBED_POOLING cls | mean (default cls — BGE models use CLS)
12
+ // WIKI_EMBED_QUANTIZED 1 | 0 (default 0 — fp32 matches fastembed)
13
+ // - never add an instruction prefix: the index was built with plain .embed().
14
+ //
15
+ // Model files download on first use (~130MB fp32) into WIKI_MODEL_CACHE if set,
16
+ // else transformers.js's default cache. Subsequent runs are offline.
17
+
18
+ const MODEL = process.env.WIKI_EMBED_MODEL || 'Xenova/bge-small-en-v1.5'
19
+ const POOLING = process.env.WIKI_EMBED_POOLING || 'cls'
20
+ const QUANTIZED = process.env.WIKI_EMBED_QUANTIZED === '1'
21
+
22
+ // transformers.js statically imports onnxruntime-node, whose native binary
23
+ // needs glibc. On musl containers (node:alpine — the public farm) that import
24
+ // throws "Error loading shared library ld-linux-x86-64.so.2". Probe the
25
+ // native runtime with a CJS require first; if it can't load, register a
26
+ // module hook (Node >= 18.19) that redirects the onnxruntime-node import to
27
+ // onnxruntime-web — the WASM backend runs everywhere and benchmarks ~25ms a
28
+ // query. WIKI_EMBED_FORCE_WASM=1 forces the WASM path for testing.
29
+
30
+ const nativeOrtLoads = () => {
31
+ if (process.env.WIKI_EMBED_FORCE_WASM === '1') return false
32
+ try { require('onnxruntime-node'); return true } catch { return false }
33
+ }
34
+
35
+ let extractorPromise = null
36
+
37
+ const getExtractor = () => {
38
+ if (!extractorPromise) {
39
+ extractorPromise = (async () => {
40
+ if (!nativeOrtLoads()) {
41
+ const { register } = require('node:module')
42
+ if (typeof register !== 'function') {
43
+ throw new Error('native onnxruntime unavailable and Node lacks ' +
44
+ 'module.register (need >= 18.19) for the WASM fallback')
45
+ }
46
+ register('./ort-hooks.mjs', require('node:url').pathToFileURL(__filename))
47
+ console.log('[wiki-plugin-similarity] native onnxruntime unavailable — using WASM backend')
48
+ }
49
+ // Dynamic import: @xenova/transformers is ESM-only; this file must stay CJS.
50
+ const { pipeline, env } = await import('@xenova/transformers')
51
+ if (process.env.WIKI_MODEL_CACHE) env.cacheDir = process.env.WIKI_MODEL_CACHE
52
+ if (env.backends?.onnx?.wasm) env.backends.onnx.wasm.numThreads = 1
53
+ return pipeline('feature-extraction', MODEL, { quantized: QUANTIZED })
54
+ })()
55
+ extractorPromise.catch(() => { extractorPromise = null }) // allow retry
56
+ }
57
+ return extractorPromise
58
+ }
59
+
60
+ // Embed one text → plain number[384], unit-normalised.
61
+ const embed = async text => {
62
+ const extractor = await getExtractor()
63
+ const out = await extractor(text, { pooling: POOLING, normalize: true })
64
+ return Array.from(out.data)
65
+ }
66
+
67
+ // Warm the model in the background (call at startup; failures just defer to
68
+ // first request).
69
+ const warm = () => { getExtractor().catch(() => {}) }
70
+
71
+ module.exports = { embed, warm }
@@ -0,0 +1,102 @@
1
+ // wiki-plugin-similarity — shared farm helpers (CommonJS, see server/package.json)
2
+ //
3
+ // Used by server.js, search-report.js and farm-search.js. A "farms" list is
4
+ // [[rootPath, kind], ...] where kind is 'local' (the primary farm this wiki
5
+ // serves from) or 'public' (extra farms, e.g. the Nextcloud mirror).
6
+
7
+ const fs = require('node:fs')
8
+ const path = require('node:path')
9
+
10
+ // ── Glob matching — supports * and ?, no path separator semantics ─────────────
11
+
12
+ const globMatch = (pattern, str) => {
13
+ const p = pattern.length
14
+ const s = str.length
15
+ const dp = Array.from({ length: p + 1 }, () => new Array(s + 1).fill(false))
16
+ dp[0][0] = true
17
+ for (let i = 1; i <= p; i++) {
18
+ if (pattern[i - 1] === '*') dp[i][0] = dp[i - 1][0]
19
+ }
20
+ for (let i = 1; i <= p; i++) {
21
+ for (let j = 1; j <= s; j++) {
22
+ if (pattern[i - 1] === '*') {
23
+ dp[i][j] = dp[i - 1][j] || dp[i][j - 1]
24
+ } else if (pattern[i - 1] === '?' || pattern[i - 1] === str[j - 1]) {
25
+ dp[i][j] = dp[i - 1][j - 1]
26
+ }
27
+ }
28
+ }
29
+ return dp[p][s]
30
+ }
31
+
32
+ // ── Scope keywords (exact uppercase, per DSL convention) ──────────────────────
33
+ // * all farms
34
+ // PUBLIC domains in 'public' farms (Nextcloud mirror)
35
+ // LOCAL domains in the primary ('local') farm
36
+ // PRIVATE public domains marked "restricted": true in a farm config
37
+
38
+ const loadRestricted = publicFarms => {
39
+ const restricted = new Set()
40
+ for (const farm of publicFarms) {
41
+ let files
42
+ try { files = fs.readdirSync(farm) } catch { continue }
43
+ for (const f of files) {
44
+ if (!/^config-.*\.json$/.test(f)) continue
45
+ try {
46
+ const cfg = JSON.parse(fs.readFileSync(path.join(farm, f), 'utf8'))
47
+ for (const [domain, opts] of Object.entries(cfg.wikiDomains || {})) {
48
+ if (opts && opts.restricted) restricted.add(domain)
49
+ }
50
+ } catch { /* ignore malformed config */ }
51
+ }
52
+ }
53
+ return restricted
54
+ }
55
+
56
+ const matchesAny = (domain, kind, patterns, restricted) =>
57
+ patterns.some(p => {
58
+ if (p === '*') return true
59
+ if (p === 'PUBLIC') return kind === 'public'
60
+ if (p === 'LOCAL') return kind === 'local'
61
+ if (p === 'PRIVATE') return kind === 'public' && restricted.has(domain)
62
+ return globMatch(p, domain)
63
+ })
64
+
65
+ // ── Domain listing ────────────────────────────────────────────────────────────
66
+ // List domains across farms matching the patterns, optionally requiring a file
67
+ // (relative to the domain dir) to exist. First farm wins on duplicate names.
68
+ // Returns [{farm, kind, domain}].
69
+
70
+ const listDomains = (farms, patterns, restricted, requireFile = null) => {
71
+ const seen = new Set()
72
+ const out = []
73
+ for (const [farm, kind] of farms) {
74
+ let entries
75
+ try { entries = fs.readdirSync(farm, { withFileTypes: true }) } catch { continue }
76
+ for (const ent of entries) {
77
+ if (!ent.isDirectory()) continue
78
+ const domain = ent.name
79
+ if (seen.has(domain)) continue
80
+ if (!matchesAny(domain, kind, patterns, restricted)) continue
81
+ if (requireFile) {
82
+ try { fs.accessSync(path.join(farm, domain, requireFile), fs.constants.F_OK) }
83
+ catch { continue }
84
+ }
85
+ seen.add(domain)
86
+ out.push({ farm, kind, domain })
87
+ }
88
+ }
89
+ out.sort((a, b) => a.domain.localeCompare(b.domain))
90
+ return out
91
+ }
92
+
93
+ // First existing path for domain + relative sub-path across farm roots.
94
+ const findInFarms = (farms, domain, relPath) => {
95
+ for (const [farm] of farms) {
96
+ const full = path.join(farm, domain, relPath)
97
+ try { fs.accessSync(full, fs.constants.F_OK); return full } catch { /* next */ }
98
+ }
99
+ return null
100
+ }
101
+
102
+ module.exports = { globMatch, loadRestricted, matchesAny, listDomains, findInFarms }
@@ -0,0 +1,122 @@
1
+ // wiki-plugin-similarity — galactic keyword search (CommonJS)
2
+ //
3
+ // Fedwiki maintains a serialized MiniSearch index per site: the wiki-server
4
+ // updates {domain}/status/site-index.json on every edit. This module READS
5
+ // those indexes across every domain on the farm(s) — no index building of any
6
+ // kind. On the farm server the files are per-edit fresh because the server
7
+ // itself writes them.
8
+ //
9
+ // The server-built index stores no fields (storedFields is empty), so display
10
+ // titles come from each domain's status/sitemap.json.
11
+ //
12
+ // Loaded indexes are cached per domain and invalidated by file mtime.
13
+
14
+ const fs = require('node:fs')
15
+ const path = require('node:path')
16
+ const crypto = require('node:crypto')
17
+
18
+ const MiniSearch = require('minisearch')
19
+ const { listDomains } = require('./farm-lib')
20
+
21
+ // site-index.json was serialized with these options; loadJSON needs them.
22
+ const MS_OPTIONS = { fields: ['title', 'content'] }
23
+
24
+ const makeId = () => crypto.randomBytes(8).toString('hex')
25
+
26
+ // ── Per-domain index cache, mtime-invalidated ─────────────────────────────────
27
+
28
+ const cache = new Map() // domain → {mtime, ms, titles, synopses}
29
+
30
+ const loadDomainIndex = (farm, domain) => {
31
+ const indexPath = path.join(farm, domain, 'status', 'site-index.json')
32
+ let mtime
33
+ try { mtime = fs.statSync(indexPath).mtimeMs } catch { return null }
34
+
35
+ const hit = cache.get(domain)
36
+ if (hit && hit.mtime === mtime) return hit
37
+
38
+ let ms
39
+ try {
40
+ ms = MiniSearch.loadJSON(fs.readFileSync(indexPath, 'utf8'), MS_OPTIONS)
41
+ } catch { return null }
42
+
43
+ // Titles + synopses from the sitemap (the index stores no fields).
44
+ const titles = new Map()
45
+ const synopses = new Map()
46
+ try {
47
+ const sitemap = JSON.parse(fs.readFileSync(
48
+ path.join(farm, domain, 'status', 'sitemap.json'), 'utf8'))
49
+ for (const p of sitemap) {
50
+ titles.set(p.slug, p.title)
51
+ if (p.synopsis) synopses.set(p.slug, p.synopsis)
52
+ }
53
+ } catch { /* titles fall back to slugs */ }
54
+
55
+ const entry = { mtime, ms, titles, synopses }
56
+ cache.set(domain, entry)
57
+ return entry
58
+ }
59
+
60
+ // ── Search across domains ─────────────────────────────────────────────────────
61
+
62
+ const searchFarm = (farms, patterns, restricted, query, limit) => {
63
+ const domains = listDomains(farms, patterns, restricted, 'status/site-index.json')
64
+ const results = []
65
+ let searched = 0
66
+ for (const { farm, domain } of domains) {
67
+ const entry = loadDomainIndex(farm, domain)
68
+ if (!entry) continue
69
+ searched += 1
70
+ let hits
71
+ try { hits = entry.ms.search(query, { boost: { title: 2 } }) } catch { continue }
72
+ for (const h of hits) {
73
+ results.push({
74
+ domain,
75
+ slug: String(h.id),
76
+ title: entry.titles.get(String(h.id)) || String(h.id),
77
+ synopsis: entry.synopses.get(String(h.id)) || '',
78
+ score: h.score,
79
+ })
80
+ }
81
+ }
82
+ results.sort((a, b) => b.score - a.score)
83
+ return { results: results.slice(0, limit), searched, matched: results.length }
84
+ }
85
+
86
+ // ── Report page JSON (ghost-page ready, mirrors search-report style) ──────────
87
+
88
+ const keywordReportPage = (query, { results, searched, matched }, limit, specs) => {
89
+ const story = [{
90
+ type: 'markdown', id: makeId(),
91
+ text: `Keyword search for **${query}** — searched the live site indexes of ` +
92
+ `${searched} domains; ${matched} matches, top ${Math.min(limit, results.length)} shown.\n\n` +
93
+ `<small>Config — domains: ${(specs || ['*']).join(', ')}; limit: ${limit}. ` +
94
+ `Reads each site's own per-edit MiniSearch index (site-index.json) — ` +
95
+ `no separate index is built.</small>`,
96
+ }]
97
+
98
+ const seenTitles = new Set()
99
+ const uniqueTitles = results.map(r => r.title).filter(t => {
100
+ if (seenTitles.has(t)) return false
101
+ seenTitles.add(t)
102
+ return true
103
+ })
104
+ if (uniqueTitles.length) {
105
+ story.push({
106
+ type: 'markdown', id: makeId(),
107
+ text: uniqueTitles.map(t => `- [[${t}]]`).join('\n'),
108
+ })
109
+ }
110
+
111
+ story.push({ type: 'markdown', id: makeId(), text: '# Results' })
112
+ for (const r of results) {
113
+ story.push({
114
+ type: 'reference', id: makeId(),
115
+ site: r.domain, slug: r.slug, title: r.title,
116
+ text: r.synopsis || `score ${r.score.toFixed(2)}`,
117
+ })
118
+ }
119
+ return { title: `${query} Keyword Search`, story }
120
+ }
121
+
122
+ module.exports = { searchFarm, keywordReportPage }
@@ -0,0 +1,6 @@
1
+ export async function resolve(specifier, context, nextResolve) {
2
+ if (specifier === 'onnxruntime-node') {
3
+ return nextResolve(new URL('./ort-shim.mjs', import.meta.url).href, context)
4
+ }
5
+ return nextResolve(specifier, context)
6
+ }
@@ -0,0 +1,2 @@
1
+ import ort from 'onnxruntime-web'
2
+ export default ort
Binary file