mnemonad-cli 0.2.0 → 0.3.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.
@@ -0,0 +1,180 @@
1
+ import { Tokenizer } from '@huggingface/tokenizers';
2
+ import EmbeddingProvider from './EmbeddingProvider.js';
3
+ import { DEFAULT_MODEL, EMBEDDER_STATIC } from '../schema.js';
4
+
5
+ /**
6
+ * The built-in embedder: static (model2vec) embeddings — `minishlab/potion-base-8M` by
7
+ * default. A static model is a lookup table with one vector per token; a text's embedding is
8
+ * the mean of its tokens' vectors, normalized. No neural network runs, so there's no ONNX
9
+ * Runtime, no native code and no WASM: plain JS that behaves identically in Node and in the
10
+ * browser, embedding a whole folder in milliseconds.
11
+ *
12
+ * The trade-off is quality on paraphrased queries — word order and context are invisible to
13
+ * it. See docs/search.md for how it compares with MiniLM on this project's own corpora; MiniLM
14
+ * stays available through the optional `mnemonad-search-transformers` package.
15
+ *
16
+ * Mirrors model2vec's own `StaticModel.encode` exactly: tokenize without special tokens, drop
17
+ * the unknown token, cap at `MAX_TOKENS`, average the rows, normalize when the model's config
18
+ * says so. Models with a separate weights vector or a token mapping (newer model2vec
19
+ * features) aren't supported; potion-base-8M uses neither.
20
+ *
21
+ * The model's files come from a `files` source, so this class never touches a filesystem or a
22
+ * cache itself — see modelFiles.node.js (disk cache) and modelFiles.browser.js (Cache API).
23
+ */
24
+
25
+ /** model2vec's own default cap (StaticModel's max_length). */
26
+ const MAX_TOKENS = 512;
27
+
28
+ export const MODEL_FILES = ['config.json', 'tokenizer.json', 'tokenizer_config.json', 'model.safetensors'];
29
+
30
+ export default class StaticEmbeddingProvider extends EmbeddingProvider {
31
+ /**
32
+ * @param {Object} params
33
+ * @param {{get(name: string): Promise<Uint8Array>}} params.files - the model's files, by name
34
+ * (see MODEL_FILES)
35
+ * @param {string} [params.model='minishlab/potion-base-8M']
36
+ */
37
+ constructor({ files, model = DEFAULT_MODEL } = {}) {
38
+ super();
39
+ if (!files) throw new Error('StaticEmbeddingProvider: a `files` source is required');
40
+ this._files = files;
41
+ this._model = model;
42
+ this._table = null;
43
+ this._dim = null;
44
+ this._loading = null;
45
+ }
46
+
47
+ get dimension() {
48
+ if (this._dim === null) {
49
+ throw new Error('StaticEmbeddingProvider: call load() or embed() before reading dimension');
50
+ }
51
+ return this._dim;
52
+ }
53
+
54
+ get modelId() {
55
+ return this._model;
56
+ }
57
+
58
+ get embedder() {
59
+ return EMBEDDER_STATIC;
60
+ }
61
+
62
+ get isLoaded() {
63
+ return this._table !== null;
64
+ }
65
+
66
+ /** Safe to call more than once, and concurrently: one shared load. */
67
+ async load() {
68
+ if (this._table) return;
69
+ if (!this._loading) {
70
+ this._loading = this._load();
71
+ this._loading.catch(() => {}).finally(() => { this._loading = null; });
72
+ }
73
+ await this._loading;
74
+ }
75
+
76
+ async _load() {
77
+ const decoder = new TextDecoder();
78
+ const [configBytes, tokenizerBytes, tokenizerConfigBytes, weights] = await Promise.all(
79
+ MODEL_FILES.map((name) => this._files.get(name))
80
+ );
81
+ const config = JSON.parse(decoder.decode(configBytes));
82
+ const tokenizerJson = JSON.parse(decoder.decode(tokenizerBytes));
83
+ const tokenizerConfig = JSON.parse(decoder.decode(tokenizerConfigBytes));
84
+
85
+ this._tokenizer = new Tokenizer(tokenizerJson, tokenizerConfig);
86
+ this._unkId = unknownTokenId(tokenizerJson);
87
+ this._normalize = config.normalize !== false;
88
+ const { table, rows, dim } = readEmbeddings(weights);
89
+ this._rows = rows;
90
+ this._dim = dim;
91
+ this._table = table;
92
+ }
93
+
94
+ async embed(texts) {
95
+ await this.load();
96
+ return texts.map((text) => this._embedOne(text));
97
+ }
98
+
99
+ _embedOne(text) {
100
+ const dim = this._dim;
101
+ const out = new Float32Array(dim);
102
+ // Truncated first, then the unknown token dropped — the same order as model2vec (its
103
+ // tokenizer truncates, its tokenize() filters afterwards).
104
+ const ids = this._tokenizer.encode(text, { add_special_tokens: false }).ids.slice(0, MAX_TOKENS);
105
+ let count = 0;
106
+ for (const id of ids) {
107
+ if (id === this._unkId || id < 0 || id >= this._rows) continue;
108
+ const base = id * dim;
109
+ for (let j = 0; j < dim; j++) out[j] += this._table[base + j];
110
+ count++;
111
+ }
112
+ if (count === 0) return out;
113
+ let norm = 0;
114
+ for (let j = 0; j < dim; j++) {
115
+ out[j] /= count;
116
+ norm += out[j] * out[j];
117
+ }
118
+ if (this._normalize && norm > 0) {
119
+ norm = Math.sqrt(norm);
120
+ for (let j = 0; j < dim; j++) out[j] /= norm;
121
+ }
122
+ return out;
123
+ }
124
+ }
125
+
126
+ /** The vocabulary id of the tokenizer's unknown token, or null if it has none. */
127
+ function unknownTokenId(tokenizerJson) {
128
+ const model = tokenizerJson.model || {};
129
+ const unk = model.unk_token;
130
+ if (unk == null) return null;
131
+ const vocab = model.vocab;
132
+ if (Array.isArray(vocab)) {
133
+ // Unigram vocabularies are [token, score] pairs.
134
+ const index = vocab.findIndex((entry) => entry[0] === unk);
135
+ return index >= 0 ? index : null;
136
+ }
137
+ return vocab && Object.hasOwn(vocab, unk) ? vocab[unk] : null;
138
+ }
139
+
140
+ /**
141
+ * Reads the `embeddings` tensor out of a safetensors file: an 8-byte little-endian header
142
+ * length, a JSON header naming each tensor's dtype, shape and byte range, then the raw data.
143
+ *
144
+ * @param {Uint8Array} bytes
145
+ * @returns {{table: Float32Array, rows: number, dim: number}}
146
+ */
147
+ export function readEmbeddings(bytes) {
148
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
149
+ const headerLength = Number(view.getBigUint64(0, true));
150
+ const header = JSON.parse(new TextDecoder().decode(bytes.subarray(8, 8 + headerLength)));
151
+ const tensor = header.embeddings;
152
+ if (!tensor) throw new Error('model.safetensors has no `embeddings` tensor — not a model2vec model?');
153
+ const [rows, dim] = tensor.shape;
154
+ const [start, end] = tensor.data_offsets;
155
+ const data = bytes.subarray(8 + headerLength + start, 8 + headerLength + end);
156
+
157
+ if (tensor.dtype === 'F32') {
158
+ // Copied rather than viewed: a Float32Array needs 4-byte alignment, which the data
159
+ // start (right after a variable-length header) doesn't guarantee.
160
+ const table = new Float32Array(rows * dim);
161
+ new Uint8Array(table.buffer).set(data);
162
+ return { table, rows, dim };
163
+ }
164
+ if (tensor.dtype === 'F16') {
165
+ const table = new Float32Array(rows * dim);
166
+ const halves = new DataView(data.buffer, data.byteOffset, data.byteLength);
167
+ for (let i = 0; i < table.length; i++) table[i] = halfToFloat(halves.getUint16(i * 2, true));
168
+ return { table, rows, dim };
169
+ }
170
+ throw new Error(`model.safetensors: unsupported dtype ${tensor.dtype} (expected F32 or F16)`);
171
+ }
172
+
173
+ function halfToFloat(h) {
174
+ const sign = h & 0x8000 ? -1 : 1;
175
+ const exp = (h >> 10) & 0x1f;
176
+ const frac = h & 0x03ff;
177
+ if (exp === 0) return sign * 2 ** -14 * (frac / 1024);
178
+ if (exp === 0x1f) return frac ? NaN : sign * Infinity;
179
+ return sign * 2 ** (exp - 15) * (1 + frac / 1024);
180
+ }
@@ -0,0 +1,28 @@
1
+ import { downloadWithProgress, hubUrl } from './modelFiles.shared.js';
2
+
3
+ /**
4
+ * A model's files for StaticEmbeddingProvider in a browser (or a worker): fetched from the
5
+ * Hugging Face hub once, then served from the Cache API, so revisits don't download again.
6
+ * Falls back to plain fetching where the Cache API isn't available (an insecure context, say).
7
+ *
8
+ * @param {string} model - e.g. 'minishlab/potion-base-8M'
9
+ * @param {Object} [opts]
10
+ * @param {(event: Object) => void} [opts.onProgress] - `{status: 'progress', file, loaded, total}`
11
+ */
12
+ export function browserModelFiles(model, { onProgress = null, cacheName = 'mnemonad-models' } = {}) {
13
+ return {
14
+ async get(name) {
15
+ const url = hubUrl(model, name);
16
+ const cache = typeof caches !== 'undefined' ? await caches.open(cacheName).catch(() => null) : null;
17
+ const hit = cache && await cache.match(url);
18
+ if (hit) return new Uint8Array(await hit.arrayBuffer());
19
+
20
+ const bytes = await downloadWithProgress(url, name, onProgress);
21
+ if (cache) {
22
+ // Best effort: a full quota just means downloading again next time.
23
+ await cache.put(url, new Response(bytes, { headers: { 'content-length': String(bytes.byteLength) } })).catch(() => {});
24
+ }
25
+ return bytes;
26
+ },
27
+ };
28
+ }
@@ -0,0 +1,41 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { downloadWithProgress, hubUrl } from './modelFiles.shared.js';
5
+
6
+ /**
7
+ * A model's files for StaticEmbeddingProvider under Node: downloaded from the Hugging Face hub
8
+ * once, then read from a local cache — `$MNEMONAD_CACHE_DIR`, else `$XDG_CACHE_HOME/mnemonad`,
9
+ * else `~/.cache/mnemonad`, under `models/<model id>/`. Written through a temp file and a
10
+ * rename, so an interrupted download never leaves a truncated file behind to be read as valid.
11
+ *
12
+ * @param {string} model - e.g. 'minishlab/potion-base-8M'
13
+ * @param {Object} [opts]
14
+ * @param {(event: Object) => void} [opts.onProgress] - `{status: 'progress', file, loaded, total}`
15
+ * @param {string} [opts.cacheDir]
16
+ */
17
+ export function nodeModelFiles(model, { onProgress = null, cacheDir = defaultCacheDir() } = {}) {
18
+ const dir = join(cacheDir, 'models', ...model.split('/'));
19
+ return {
20
+ async get(name) {
21
+ const path = join(dir, name);
22
+ try {
23
+ return new Uint8Array(await readFile(path));
24
+ } catch (err) {
25
+ if (err.code !== 'ENOENT') throw err;
26
+ }
27
+ const bytes = await downloadWithProgress(hubUrl(model, name), name, onProgress);
28
+ await mkdir(dir, { recursive: true });
29
+ const tmp = `${path}.${process.pid}.tmp`;
30
+ await writeFile(tmp, bytes);
31
+ await rename(tmp, path);
32
+ return bytes;
33
+ },
34
+ };
35
+ }
36
+
37
+ export function defaultCacheDir() {
38
+ if (process.env.MNEMONAD_CACHE_DIR) return process.env.MNEMONAD_CACHE_DIR;
39
+ if (process.env.XDG_CACHE_HOME) return join(process.env.XDG_CACHE_HOME, 'mnemonad');
40
+ return join(homedir(), '.cache', 'mnemonad');
41
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The download half of a model file source, shared by Node (modelFiles.node.js) and the browser
3
+ * (modelFiles.browser.js) — plain `fetch`, which both have.
4
+ */
5
+
6
+ const HUB = 'https://huggingface.co';
7
+
8
+ /** @returns {string} where the hub serves `name` from `model`'s main branch. */
9
+ export function hubUrl(model, name) {
10
+ return `${HUB}/${model}/resolve/main/${name}`;
11
+ }
12
+
13
+ /**
14
+ * Fetches `url` into memory, reporting progress in the same shape transformers.js does
15
+ * (`{status: 'progress', file, loaded, total}`), so one progress display works for either
16
+ * kind of model.
17
+ *
18
+ * @returns {Promise<Uint8Array>}
19
+ */
20
+ export async function downloadWithProgress(url, name, onProgress) {
21
+ const res = await fetch(url);
22
+ if (!res.ok) throw new Error(`couldn't download ${name} (${res.status} ${res.statusText}) from ${url}`);
23
+ const total = Number(res.headers.get('content-length')) || 0;
24
+ if (!res.body || !onProgress) return new Uint8Array(await res.arrayBuffer());
25
+
26
+ const reader = res.body.getReader();
27
+ const parts = [];
28
+ let loaded = 0;
29
+ for (;;) {
30
+ const { done, value } = await reader.read();
31
+ if (done) break;
32
+ parts.push(value);
33
+ loaded += value.byteLength;
34
+ onProgress({ status: 'progress', file: name, loaded, total: total || loaded });
35
+ }
36
+ const bytes = new Uint8Array(loaded);
37
+ let offset = 0;
38
+ for (const part of parts) {
39
+ bytes.set(part, offset);
40
+ offset += part.byteLength;
41
+ }
42
+ onProgress({ status: 'done', file: name, loaded, total: total || loaded });
43
+ return bytes;
44
+ }
@@ -0,0 +1,18 @@
1
+ // The search engine behind `mnemonad index` / `search` / `--index` — Node side. The browser
2
+ // half (for the explorer) is ./browser.js, which must stay free of native and `node:*` imports.
3
+ export { default as VectorIndex } from './VectorIndex.js';
4
+ export { default as Indexer, ModelMismatchError } from './Indexer.js';
5
+ export { default as Searcher, describeIndexModel } from './Searcher.js';
6
+ export { default as EmbeddingProvider } from './embeddings/EmbeddingProvider.js';
7
+ export { default as StaticEmbeddingProvider } from './embeddings/StaticEmbeddingProvider.js';
8
+ export { nodeModelFiles } from './embeddings/modelFiles.node.js';
9
+ export { default as ChunkingStrategy } from './chunking/ChunkingStrategy.js';
10
+ export { default as TextWindowChunkingStrategy } from './chunking/TextWindowChunkingStrategy.js';
11
+ export {
12
+ createProvider, loadTransformersExtension, findTransformersExtension, MissingExtensionError,
13
+ EXTENSION_PACKAGE, EXTENSION_INSTALL,
14
+ } from './providers.js';
15
+ export {
16
+ DEFAULT_DB_NAME, DEFAULT_MODEL, MODEL_ALIASES, resolveModelName, embedderForModel, embedderOfIndex,
17
+ EMBEDDER_STATIC, EMBEDDER_TRANSFORMERS,
18
+ } from './schema.js';
@@ -0,0 +1,153 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readdirSync, statSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { join } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import StaticEmbeddingProvider from './embeddings/StaticEmbeddingProvider.js';
7
+ import { nodeModelFiles } from './embeddings/modelFiles.node.js';
8
+ import { EMBEDDER_STATIC, EMBEDDER_TRANSFORMERS, embedderForModel } from './schema.js';
9
+
10
+ /**
11
+ * Embedding providers for Node — the built-in static one, or one from the optional
12
+ * transformers extension.
13
+ *
14
+ * The extension (`mnemonad-search-transformers`) is deliberately *not* a dependency of the CLI:
15
+ * it brings ONNX Runtime, ~450 MB installed, for the one feature (MiniLM-style neural models)
16
+ * most users never need. It's installed separately, and found at runtime:
17
+ *
18
+ * 1. A plain `import()`. Node resolves it by walking up from this file's own folder, which
19
+ * reaches it for a local project install, `npx -p … -p …`, and npm's global installs —
20
+ * global packages sit side by side in `<prefix>/lib/node_modules`, which the walk reaches.
21
+ * 2. Failing that, the package managers' own global folders: `npm root -g` (for layouts the
22
+ * walk doesn't reach), then `pnpm root -g`. pnpm 11 installs every global package into its
23
+ * own isolated folder (`<root>/<hash>/node_modules/<pkg>`), so there the CLI can never see
24
+ * a sibling by walking up; each of those folders is checked instead, newest first.
25
+ */
26
+
27
+ export const EXTENSION_PACKAGE = 'mnemonad-search-transformers';
28
+
29
+ /** What this CLI expects the extension's interface to look like; the extension exports its own. */
30
+ export const EXTENSION_API_VERSION = 1;
31
+
32
+ export const EXTENSION_INSTALL = `npm install -g ${EXTENSION_PACKAGE} (or: pnpm add -g ${EXTENSION_PACKAGE})`;
33
+
34
+ export class MissingExtensionError extends Error {
35
+ constructor(model) {
36
+ super(
37
+ `${model} needs the optional transformers extension (about 450 MB installed, not included by default):\n` +
38
+ ` ${EXTENSION_INSTALL}`
39
+ );
40
+ this.name = 'MissingExtensionError';
41
+ this.model = model;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * @param {Object} [opts] - both overridable for tests
47
+ * @param {(specifier: string) => Promise<any>} [opts.importer]
48
+ * @param {() => string[]} [opts.globalRoots] - global package folders to look in (see above)
49
+ * @returns {Promise<?any>} the extension module, or null when it isn't installed
50
+ */
51
+ export async function findTransformersExtension({ importer = (s) => import(s), globalRoots = defaultGlobalRoots } = {}) {
52
+ try {
53
+ return await importer(EXTENSION_PACKAGE);
54
+ } catch (err) {
55
+ if (!isNotFound(err)) throw err;
56
+ }
57
+ for (const root of globalRoots()) {
58
+ const pkgJson = extensionIn(root);
59
+ if (!pkgJson) continue;
60
+ // Resolved from inside the package itself (a self-reference through its own `exports`),
61
+ // so the entry point is whatever the package declares, not a guessed file name.
62
+ const entry = createRequire(pkgJson).resolve(EXTENSION_PACKAGE);
63
+ return importer(pathToFileURL(entry).href);
64
+ }
65
+ return null;
66
+ }
67
+
68
+ /**
69
+ * The extension's package.json under one global folder, or null. Only these exact places —
70
+ * never a module-resolution walk, which could wander out of the folder and pick up some
71
+ * unrelated copy:
72
+ * - `<root>/<package>` — npm, and older pnpm, where global packages sit side by side;
73
+ * - `<root>/<dir>/node_modules/<package>` — pnpm 11's isolated per-package folders; newest
74
+ * first, since reinstalling leaves an older folder behind until pnpm prunes it.
75
+ */
76
+ function extensionIn(root) {
77
+ const direct = join(root, EXTENSION_PACKAGE, 'package.json');
78
+ if (existsSync(direct)) return direct;
79
+ let dirs;
80
+ try {
81
+ dirs = readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory());
82
+ } catch {
83
+ return null;
84
+ }
85
+ const found = dirs
86
+ .map((d) => join(root, d.name, 'node_modules', EXTENSION_PACKAGE, 'package.json'))
87
+ .filter((p) => existsSync(p))
88
+ .map((p) => ({ p, mtime: statSync(p).mtimeMs }))
89
+ .sort((a, b) => b.mtime - a.mtime);
90
+ return found.length ? found[0].p : null;
91
+ }
92
+
93
+ /**
94
+ * @param {string} model - for the error message
95
+ * @param {Object} [opts] - see findTransformersExtension
96
+ */
97
+ export async function loadTransformersExtension(model, opts) {
98
+ const ext = await findTransformersExtension(opts);
99
+ if (!ext) throw new MissingExtensionError(model);
100
+ if (ext.apiVersion !== EXTENSION_API_VERSION) {
101
+ throw new Error(
102
+ `${EXTENSION_PACKAGE} speaks extension API v${ext.apiVersion ?? '?'}, but this mnemonad-cli expects ` +
103
+ `v${EXTENSION_API_VERSION} — update both: npm install -g mnemonad-cli@latest ${EXTENSION_PACKAGE}@latest`
104
+ );
105
+ }
106
+ return ext;
107
+ }
108
+
109
+ /**
110
+ * @param {Object} params
111
+ * @param {string} params.model - full model id
112
+ * @param {string} [params.embedder] - defaults to what the model needs (see embedderForModel)
113
+ * @param {?string} [params.dtype] - transformers models only
114
+ * @param {(event: Object) => void} [params.onProgress]
115
+ * @param {Object} [params.extension] - passed to loadTransformersExtension (tests)
116
+ */
117
+ export async function createProvider({ model, embedder = embedderForModel(model), dtype = null, onProgress = null, extension } = {}) {
118
+ if (embedder === EMBEDDER_STATIC) {
119
+ return new StaticEmbeddingProvider({ model, files: nodeModelFiles(model, { onProgress }) });
120
+ }
121
+ if (embedder === EMBEDDER_TRANSFORMERS) {
122
+ const ext = await loadTransformersExtension(model, extension);
123
+ return ext.createProvider({ model, ...(dtype ? { dtype } : {}), onProgress });
124
+ }
125
+ throw new Error(`unknown embedder '${embedder}' (expected ${EMBEDDER_STATIC} or ${EMBEDDER_TRANSFORMERS})`);
126
+ }
127
+
128
+ function isNotFound(err) {
129
+ return !!err
130
+ && (err.code === 'ERR_MODULE_NOT_FOUND' || err.code === 'MODULE_NOT_FOUND')
131
+ // Only the extension itself being absent — a missing dependency *inside* an installed
132
+ // extension is a broken install, and should surface as such.
133
+ && String(err.message).includes(`'${EXTENSION_PACKAGE}'`);
134
+ }
135
+
136
+ /** npm's and pnpm's global package folders — whichever of the two is installed. Asked only
137
+ * when the plain import has already missed, so a normal run never spawns either. */
138
+ function defaultGlobalRoots() {
139
+ return ['npm', 'pnpm'].map(globalRootOf).filter(Boolean);
140
+ }
141
+
142
+ function globalRootOf(tool) {
143
+ try {
144
+ return execFileSync(tool, ['root', '-g'], {
145
+ encoding: 'utf8',
146
+ stdio: ['ignore', 'pipe', 'ignore'],
147
+ timeout: 10_000,
148
+ shell: process.platform === 'win32',
149
+ }).trim() || null;
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Everything about the on-disk index format that more than one reader has to agree on — the
3
+ * Node `VectorIndex` (better-sqlite3 + the native sqlite-vector extension) and the browser's
4
+ * `WasmIndexReader` (@sqliteai/sqlite-wasm, same sqlite-vector compiled in). Kept in one place
5
+ * so the two can't drift: a search from the explorer must rank exactly like `mnemonad search`
6
+ * on the same file.
7
+ *
8
+ * Deliberately dependency-free (no `node:*`, no native modules) — the browser entry imports it.
9
+ */
10
+
11
+ export const DEFAULT_DB_NAME = 'search_index.db';
12
+
13
+ /**
14
+ * Two kinds of embedding model, recorded per index as `embedder` in its metadata:
15
+ *
16
+ * - 'model2vec' — static embeddings (a token → vector lookup table, averaged). Built in: pure
17
+ * JS, no native code, runs the same in Node and the browser. The default.
18
+ * - 'transformers' — a neural model run by transformers.js on ONNX Runtime (MiniLM, say).
19
+ * Better on paraphrased queries, but ONNX Runtime is ~450 MB installed, so the CLI loads it
20
+ * only from the optional `mnemonad-search-transformers` package.
21
+ */
22
+ export const EMBEDDER_STATIC = 'model2vec';
23
+ export const EMBEDDER_TRANSFORMERS = 'transformers';
24
+
25
+ export const DEFAULT_MODEL = 'minishlab/potion-base-8M';
26
+
27
+ /** Short names accepted wherever a model is (`--model minilm`). */
28
+ export const MODEL_ALIASES = {
29
+ potion: 'minishlab/potion-base-8M',
30
+ minilm: 'Xenova/all-MiniLM-L6-v2',
31
+ };
32
+
33
+ /** @returns {string} the full model id for an alias, or the name unchanged. */
34
+ export function resolveModelName(name) {
35
+ return MODEL_ALIASES[name] || name;
36
+ }
37
+
38
+ /** Which embedder a model needs. model2vec models live under the `minishlab/` organisation
39
+ * on the Hugging Face hub; everything else is taken to be a transformers.js model. */
40
+ export function embedderForModel(model) {
41
+ return /^minishlab\//.test(model) ? EMBEDDER_STATIC : EMBEDDER_TRANSFORMERS;
42
+ }
43
+
44
+ /**
45
+ * Which embedder built an existing index. Indexes from before `embedder` was recorded were all
46
+ * built with MiniLM through transformers.js — the only option at the time.
47
+ *
48
+ * @param {{embedder?: ?string, model?: ?string}} meta
49
+ */
50
+ export function embedderOfIndex(meta) {
51
+ if (meta.embedder) return meta.embedder;
52
+ return meta.model ? embedderForModel(meta.model) : EMBEDDER_TRANSFORMERS;
53
+ }
54
+
55
+ /** What a transformers-built index recorded before `dtype` was written to its meta was built
56
+ * with — the Node default at the time, since the CLI was the only thing that could build one. */
57
+ export const LEGACY_DTYPE = 'fp32';
58
+
59
+ export const TABLE_CHUNKS = 'search_index';
60
+ export const TABLE_FILES = 'search_index_files';
61
+ export const TABLE_META = 'search_index_meta';
62
+ export const VECTOR_COLUMN = 'embedding';
63
+
64
+ /** @param {number} dim @param {'FLOAT32'|'INT8'} [type='FLOAT32'] */
65
+ export function vectorInitOptions(dim, type = 'FLOAT32') {
66
+ return `type=${type},dimension=${dim},distance=COSINE`;
67
+ }
68
+
69
+ export const VECTOR_INIT_SQL = `SELECT vector_init('${TABLE_CHUNKS}', '${VECTOR_COLUMN}', ?)`;
70
+
71
+ /**
72
+ * Nearest-neighbor search, current chunks only. Binds, in order: query vector (BLOB), overfetch
73
+ * count, k. `vector_full_scan` is exact brute force — appropriate for the corpus sizes a synced
74
+ * folder actually has, and an ANN/partitioning index would break the diffability the whole
75
+ * format depends on. Some of its top candidates may be stale (superseded by a later reindex of
76
+ * the same file, or belonging to a removed file), which the join against the files table drops
77
+ * — hence the overfetch, see `overfetchFor`.
78
+ */
79
+ export const SEARCH_SQL = `
80
+ SELECT s.path, s.chunk_index, s.start_offset, s.end_offset, v.distance
81
+ FROM vector_full_scan('${TABLE_CHUNKS}', '${VECTOR_COLUMN}', ?, ?) v
82
+ JOIN ${TABLE_CHUNKS} s ON s.id = v.rowid
83
+ JOIN ${TABLE_FILES} f ON f.path = s.path AND f.content_hash = s.content_hash
84
+ ORDER BY v.distance ASC
85
+ LIMIT ?
86
+ `;
87
+
88
+ /** A generous overfetch — cheap (still one exact scan) and simple, rather than looping with a
89
+ * growing k until enough candidates survive the staleness join. */
90
+ export function overfetchFor(k) {
91
+ return Math.max(k * 8, 64);
92
+ }
93
+
94
+ /** @returns {{path: string, chunkIndex: number, startOffset: number, endOffset: number, distance: number}} */
95
+ export function mapSearchRow(r) {
96
+ return {
97
+ path: r.path,
98
+ chunkIndex: r.chunk_index,
99
+ startOffset: r.start_offset,
100
+ endOffset: r.end_offset,
101
+ distance: r.distance,
102
+ };
103
+ }
@@ -13,9 +13,23 @@ export default {
13
13
  // their own. Overridable per-run with --presign-url or MNEMONAD_PRESIGN_URL.
14
14
  presignUrl: 'https://presign-server.vercel.app',
15
15
 
16
- // Public IPFS gateway for *reading* externally-offloaded items (pull/diff/info/watch)
17
- // — same default the explorer dApp uses (explorer/src/mnemonad/presignProvider.js), so
18
- // a stream pushed through either tool reads back the same way with no setup. Overridable
19
- // per-run with --gateway-url or MNEMONAD_IPFS_GATEWAY.
20
- gatewayUrl: 'https://gateway.pinata.cloud/ipfs/',
16
+ // IPFS gateway for *reading* externally-offloaded items (pull/diff/info/watch) — same
17
+ // default the explorer dApp uses (explorer/src/mnemonad/presignProvider.js), so a stream
18
+ // pushed through either tool reads back the same way with no setup. Overridable per-run
19
+ // with --gateway-url or MNEMONAD_IPFS_GATEWAY.
20
+ //
21
+ // A Pinata *dedicated* gateway, not the shared `gateway.pinata.cloud` — measured live: a
22
+ // burst of 20 rapid reads of the same CID got 20/20 429 on the shared gateway and 20/20
23
+ // (then 50/50) 200 here, fully unauthenticated. Safe to commit: it's a hostname, not a
24
+ // credential, and the content behind it is public IPFS either way. List yours with
25
+ // `GET https://api.pinata.cloud/v3/ipfs/gateways`.
26
+ gatewayUrl: 'https://azure-casual-firefly-850.mypinata.cloud/ipfs/',
27
+
28
+ // Where --passkey opens the browser to run the WebAuthn ceremony (see
29
+ // lib/passkeyBridge.js and docs/wallet/passkey-accounts.md's CLI section). Has to be this
30
+ // exact domain, not a placeholder or a local dev server — a passkey is bound to whichever
31
+ // domain created it (rp.id), so this only ever works against the same deployment the dApp
32
+ // itself runs on. Overridable per-run with --auth-origin or MNEMONAD_AUTH_ORIGIN, mainly
33
+ // for pointing at a different deployment during development of this feature itself.
34
+ authOrigin: 'https://mnemonad.vercel.app',
21
35
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnemonad-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "CLI to sync local folders to versioned, diffed on-chain streams on Monad, backed by Mnemonad + monadsync.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -8,6 +8,9 @@
8
8
  "directory": "cli"
9
9
  },
10
10
  "type": "module",
11
+ "engines": {
12
+ "node": ">=22"
13
+ },
11
14
  "license": "AGPL-3.0",
12
15
  "bin": {
13
16
  "mnemonad": "./bin/mnemonad.js"
@@ -19,14 +22,21 @@
19
22
  "README.md"
20
23
  ],
21
24
  "dependencies": {
25
+ "@huggingface/tokenizers": "^0.2.0",
26
+ "@sqliteai/sqlite-vector": "^1.1.2",
27
+ "better-sqlite3": "^13.0.3",
22
28
  "mnemonad": "^0.0.1",
23
- "monadsync": "^0.0.1",
24
- "viem": "^2.56.3"
29
+ "monadsync": "^0.0.2",
30
+ "open": "^11.0.4",
31
+ "viem": "^2.56.3",
32
+ "ws": "^8.22.0"
25
33
  },
26
34
  "devDependencies": {
35
+ "@sqliteai/sqlite-wasm": "3.50.4-wasm.1.0.0-sync.1.1.3-vector.1.1.2-memory.1.3.5",
36
+ "mnemonad-search-transformers": "^0.1.0",
27
37
  "vitest": "^4.1.11"
28
38
  },
29
39
  "scripts": {
30
- "test": "vitest run ./test/*.test.js"
40
+ "test": "vitest run ./test/*.test.js ./test/search/*.test.js"
31
41
  }
32
42
  }