@mrplex/embedder 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +98 -0
  2. package/embedder.mjs +190 -0
  3. package/package.json +45 -0
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # @mrplex/embedder
2
+
3
+ A ready-to-use local embedding provider for [mrplex](https://github.com/usergenic/mrplex)'s
4
+ `--embed-cmd` hook. It runs `bge-small-en-v1.5` (384-dim) on CPU via ONNX — no GPU, no
5
+ separate service. mrplex spawns it once and keeps it resident, so the model loads a single
6
+ time and each batch is one JSON line in / one JSON line out.
7
+
8
+ ## Why a separate package
9
+
10
+ This lives in its own package with its own `package.json` and lockfile so that
11
+ its dependency — `fastembed`, and `fastembed`'s transitive `tar` — never enters
12
+ mrplex's core dependency graph. A plain `npm install` (or `npm ci`) at the
13
+ mrplex root stays clean; you only pull these deps if you opt in to local
14
+ embeddings.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install -g @mrplex/embedder
20
+ ```
21
+
22
+ The model (~130 MB) auto-downloads and caches on first run (typically under
23
+ `~/.cache/huggingface` or fastembed's cache directory).
24
+
25
+ **From the mrplex monorepo** (development):
26
+
27
+ ```bash
28
+ cd packages/embedder && npm install
29
+ ```
30
+
31
+ ## Use with mrplex
32
+
33
+ After a global install, point mrplex at the `mrplex-embedder` binary:
34
+
35
+ ```bash
36
+ # Serve with the hook — every write auto-embeds.
37
+ mrplex serve --unsafe --embed-cmd "mrplex-embedder --stdio"
38
+
39
+ # One-off backfill of an existing repo.
40
+ mrplex embed backfill -r notes --embed-cmd "mrplex-embedder --stdio"
41
+
42
+ # Or make it ambient.
43
+ export MRPLEX_EMBED_CMD="mrplex-embedder --stdio"
44
+ ```
45
+
46
+ **Monorepo / local checkout** (no global install):
47
+
48
+ ```bash
49
+ mrplex serve --unsafe \
50
+ --embed-cmd "node packages/embedder/embedder.mjs --stdio"
51
+ ```
52
+
53
+ ### Different models
54
+
55
+ ```bash
56
+ # Stronger, slower (768-dim).
57
+ export MRPLEX_EMBED_CMD="mrplex-embedder --stdio --model fast-bge-base-en-v1.5"
58
+
59
+ # Matryoshka truncation (re-embed after changing --dim).
60
+ export MRPLEX_EMBED_CMD="mrplex-embedder --stdio --dim 256"
61
+ ```
62
+
63
+ List supported model keys:
64
+
65
+ ```bash
66
+ mrplex-embedder --list-models
67
+ ```
68
+
69
+ ## Flags
70
+
71
+ - `--stdio` — required transport (one JSON line per batch over stdin/stdout).
72
+ - `--model KEY` — any `fastembed` model key. Default `fast-bge-small-en-v1.5`.
73
+ Others include `fast-all-MiniLM-L6-v2`, `fast-bge-base-en-v1.5`,
74
+ `fast-multilingual-e5-large`. Run `--list-models` for the full set.
75
+ - `--dim N` — truncate + re-normalize each vector to `N` dims (for Matryoshka
76
+ models). The truncation is encoded into the reported model string
77
+ (`<key>@<N>`) so mrplex treats a dim change as a model change and re-embeds.
78
+ - `--list-models` — print supported `--model` keys and exit.
79
+ - `--help`, `--version` — usage and version.
80
+
81
+ ## Protocol
82
+
83
+ Reads `{ "chunks": ["…", …] }` lines, writes
84
+ `{ "vectors": [[…], …], "model": "…", "dim": N }` lines. Vectors are
85
+ mean-pooled and L2-normalized (unit length) so mrplex's brute-force cosine
86
+ ranking is stable. A malformed input line still gets one `{ "error": "…" }`
87
+ line back so the caller never hangs.
88
+
89
+ ## Security note
90
+
91
+ `fastembed` depends on `tar` (used to extract the model archive it downloads
92
+ from HuggingFace on first run). The pinned `tar` carries published advisories
93
+ (`GHSA-*`, path-traversal / DoS classes). The exposure here is low: this is
94
+ opt-in developer tooling — not part of mrplex's shipped runtime — and `tar`
95
+ only ever processes archives fetched from HuggingFace, not attacker-supplied
96
+ input. `npm audit` will flag this; `npm audit fix --force` would downgrade
97
+ `fastembed` (breaking). Revisit if a `fastembed` release adopts a patched
98
+ `tar`.
package/embedder.mjs ADDED
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Local embedding provider for mrplex's `--embed-cmd` hook.
4
+ *
5
+ * Speaks the subprocess contract (design §5.3): one JSON line in on stdin
6
+ * `{ chunks: string[] }`, one JSON line out on stdout
7
+ * `{ vectors: number[][], model: string, dim: number }`. mrplex spawns this
8
+ * ONCE and reuses it for every batch (src/embed/cmd-hook.ts), so the model
9
+ * loads a single time and stays resident — no per-call cold start.
10
+ *
11
+ * mrplex serve --unsafe --embed-cmd "mrplex-embedder --stdio"
12
+ * mrplex embed backfill -r notes --embed-cmd "mrplex-embedder --stdio"
13
+ *
14
+ * Model: bge-small-en-v1.5 (384-dim) by default — strong retrieval quality for
15
+ * its size, and small vectors keep mrplex's brute-force cosine scan fast. Runs
16
+ * on CPU via ONNX; no GPU or separate service required.
17
+ */
18
+
19
+ import { readFileSync } from "node:fs";
20
+ import { createInterface } from "node:readline";
21
+ import { dirname, join } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ const pkg = JSON.parse(
25
+ readFileSync(join(dirname(fileURLToPath(import.meta.url)), "package.json"), "utf8"),
26
+ );
27
+ const VERSION = pkg.version;
28
+
29
+ const argv = process.argv.slice(2);
30
+ const flag = (name, def) => {
31
+ const i = argv.indexOf(name);
32
+ return i >= 0 ? argv[i + 1] : def;
33
+ };
34
+ const has = (name) => argv.includes(name);
35
+
36
+ function printHelp() {
37
+ console.error(`mrplex-embedder ${VERSION}
38
+ Local CPU embedding provider for mrplex's --embed-cmd hook.
39
+
40
+ Usage:
41
+ mrplex-embedder --stdio [--model KEY] [--dim N]
42
+
43
+ Options:
44
+ --stdio required transport (one JSON line per batch over stdin/stdout)
45
+ --model KEY fastembed model key (default: fast-bge-small-en-v1.5)
46
+ --dim N truncate + re-normalize to N dims (Matryoshka models only)
47
+ --list-models print supported --model keys and exit
48
+ --help, -h show this help
49
+ --version, -V print version
50
+
51
+ Install: npm install -g @mrplex/embedder
52
+ Docs: https://github.com/usergenic/mrplex/tree/main/packages/embedder`);
53
+ }
54
+
55
+ if (has("--help") || has("-h")) {
56
+ printHelp();
57
+ process.exit(0);
58
+ }
59
+ if (has("--version") || has("-V")) {
60
+ console.log(VERSION);
61
+ process.exit(0);
62
+ }
63
+
64
+ const modelKey = flag("--model", "fast-bge-small-en-v1.5");
65
+ const truncateDim = argv.includes("--dim") ? Number.parseInt(flag("--dim", ""), 10) : null;
66
+
67
+ if (truncateDim !== null && (!Number.isInteger(truncateDim) || truncateDim <= 0)) {
68
+ console.error(`embedder: --dim must be a positive integer (got ${flag("--dim", "")})`);
69
+ process.exit(2);
70
+ }
71
+
72
+ // Import lazily so a missing dependency yields a clear message, not a stack
73
+ // trace before the usage check above has run.
74
+ let FlagEmbedding;
75
+ let EmbeddingModel;
76
+ try {
77
+ ({ FlagEmbedding, EmbeddingModel } = await import("fastembed"));
78
+ } catch {
79
+ console.error(
80
+ "embedder: missing dependency 'fastembed'.\n install it with: npm install fastembed",
81
+ );
82
+ process.exit(3);
83
+ }
84
+
85
+ if (has("--list-models")) {
86
+ for (const key of Object.values(EmbeddingModel)) {
87
+ console.log(key);
88
+ }
89
+ process.exit(0);
90
+ }
91
+
92
+ if (!has("--stdio")) {
93
+ printHelp();
94
+ process.exit(2);
95
+ }
96
+
97
+ // fastembed keys its models by an enum whose values are the string keys above.
98
+ const model = Object.values(EmbeddingModel).find((v) => v === modelKey);
99
+ if (!model) {
100
+ console.error(
101
+ `embedder: unknown --model '${modelKey}'. known: ${Object.values(EmbeddingModel).join(", ")}`,
102
+ );
103
+ process.exit(2);
104
+ }
105
+
106
+ // maxLength covers the chunker's 2000-char cap (~512 tokens); bge tops out at 512.
107
+ const embedder = await FlagEmbedding.init({ model, maxLength: 512 });
108
+
109
+ /** Truncate a unit vector to `n` dims and re-normalize (Matryoshka). */
110
+ function truncate(vec, n) {
111
+ const head = vec.slice(0, n);
112
+ let sum = 0;
113
+ for (const x of head) sum += x * x;
114
+ const norm = Math.sqrt(sum) || 1;
115
+ return head.map((x) => x / norm);
116
+ }
117
+
118
+ async function embedAll(chunks) {
119
+ // `embed` yields batches; collect them back into one aligned array.
120
+ const vectors = [];
121
+ for await (const batch of embedder.embed(chunks, chunks.length)) {
122
+ for (const v of batch) vectors.push(Array.from(v));
123
+ }
124
+ return vectors;
125
+ }
126
+
127
+ function reportedModelName() {
128
+ return truncateDim !== null ? `${modelKey}@${truncateDim}` : modelKey;
129
+ }
130
+
131
+ async function handleBatch(chunks) {
132
+ const modelName = reportedModelName();
133
+ if (chunks.length === 0) {
134
+ return { vectors: [], model: modelName, dim: nativeDim };
135
+ }
136
+ let vectors = await embedAll(chunks);
137
+ if (truncateDim !== null) {
138
+ vectors = vectors.map((v) => truncate(v, truncateDim));
139
+ }
140
+ const dim = vectors[0]?.length ?? truncateDim ?? nativeDim;
141
+ return { vectors, model: modelName, dim };
142
+ }
143
+
144
+ function parseChunks(line) {
145
+ let body;
146
+ try {
147
+ body = JSON.parse(line);
148
+ } catch {
149
+ throw new Error("invalid JSON");
150
+ }
151
+ if (typeof body !== "object" || body === null || !Array.isArray(body.chunks)) {
152
+ throw new Error('request must be { "chunks": string[] }');
153
+ }
154
+ for (let i = 0; i < body.chunks.length; i++) {
155
+ if (typeof body.chunks[i] !== "string") {
156
+ throw new Error(`chunks[${i}] must be a string`);
157
+ }
158
+ }
159
+ return body.chunks;
160
+ }
161
+
162
+ // Warm up so the first real batch isn't disproportionately slow.
163
+ const warmup = await handleBatch(["warmup"]);
164
+ const nativeDim = truncateDim ?? warmup.dim;
165
+
166
+ async function respond(line) {
167
+ try {
168
+ const chunks = parseChunks(line);
169
+ const result = await handleBatch(chunks);
170
+ process.stdout.write(`${JSON.stringify(result)}\n`);
171
+ } catch (err) {
172
+ // Framing convention (stub-embedder.mjs): a failed batch still emits one
173
+ // line so the peer never hangs waiting for a response.
174
+ process.stdout.write(`${JSON.stringify({ error: String(err) })}\n`);
175
+ }
176
+ }
177
+
178
+ // Serialize batches so output lines stay in input order even if several lines
179
+ // arrive buffered together — the async handler would otherwise race. (mrplex's
180
+ // cmd-hook is one-in-flight, but this keeps the script correct on its own.)
181
+ const rl = createInterface({ input: process.stdin });
182
+ let queue = Promise.resolve();
183
+ rl.on("line", (line) => {
184
+ if (line.trim().length === 0) return;
185
+ queue = queue.then(() => respond(line));
186
+ });
187
+
188
+ console.error(
189
+ `embedder stdio model=${modelKey}${truncateDim !== null ? ` dim=${truncateDim}` : ""}`,
190
+ );
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@mrplex/embedder",
3
+ "version": "0.1.0",
4
+ "description": "Local CPU embedding provider for mrplex's --embed-cmd hook (bge-small-en-v1.5 via fastembed). Isolated from mrplex core so fastembed/tar stay out of the core dependency graph.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Brendan Baldwin <brendan@usergenic.com>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/usergenic/mrplex.git",
11
+ "directory": "packages/embedder"
12
+ },
13
+ "homepage": "https://github.com/usergenic/mrplex/tree/main/packages/embedder",
14
+ "bugs": {
15
+ "url": "https://github.com/usergenic/mrplex/issues"
16
+ },
17
+ "keywords": [
18
+ "mrplex",
19
+ "embeddings",
20
+ "semantic-search",
21
+ "fastembed",
22
+ "onnx",
23
+ "bge"
24
+ ],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "bin": {
29
+ "mrplex-embedder": "embedder.mjs"
30
+ },
31
+ "files": [
32
+ "embedder.mjs",
33
+ "README.md"
34
+ ],
35
+ "scripts": {
36
+ "start": "node embedder.mjs --stdio",
37
+ "test": "node --test test/*.test.mjs"
38
+ },
39
+ "engines": {
40
+ "node": ">=20.11.0"
41
+ },
42
+ "dependencies": {
43
+ "fastembed": "^2.1.0"
44
+ }
45
+ }