mnemonad-cli 0.1.1 → 0.3.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.
@@ -0,0 +1,93 @@
1
+ import {
2
+ TABLE_FILES, TABLE_META, VECTOR_INIT_SQL, SEARCH_SQL,
3
+ vectorInitOptions, overfetchFor, mapSearchRow,
4
+ } from '../schema.js';
5
+
6
+ /**
7
+ * Read-only view of a `search_index.db` held in memory — the browser counterpart to
8
+ * `VectorIndex`, for a page that already has the file's bytes (the explorer does: after a
9
+ * restore, the whole folder, index included, lives in memory) and no filesystem to open it from.
10
+ *
11
+ * Runs on @sqliteai/sqlite-wasm, which ships the same sqlite-vector build the Node side loads
12
+ * as a native extension, so it answers with the same `vector_full_scan` query (shared through
13
+ * schema.js) and returns the same distances — not a second, JS-side scoring that could drift.
14
+ *
15
+ * The sqlite3 module is passed in rather than imported here: it's a ~3 MB WASM payload the
16
+ * caller decides when (and whether) to load, and it keeps this package free of a hard browser-
17
+ * only dependency: `(await import('@sqliteai/sqlite-wasm')).default()` is the usual way to get one.
18
+ */
19
+ export default class WasmIndexReader {
20
+ /**
21
+ * @param {any} sqlite3 - an initialized module from `@sqliteai/sqlite-wasm`'s default export.
22
+ * @param {Uint8Array} bytes - the whole database file.
23
+ */
24
+ constructor(sqlite3, bytes) {
25
+ this._sqlite3 = sqlite3;
26
+ this._db = new sqlite3.oo1.DB(':memory:');
27
+ // sqlite3_deserialize takes ownership of a WASM-heap copy (FREEONCLOSE), so the caller's
28
+ // buffer is never referenced again — safe to hand over bytes that later get replaced by a
29
+ // live poll. READONLY: nothing here should ever write, and a stray write would otherwise
30
+ // silently diverge this copy from the file actually in the stream.
31
+ const ptr = sqlite3.wasm.allocFromTypedArray(bytes);
32
+ const { capi } = sqlite3;
33
+ const rc = capi.sqlite3_deserialize(
34
+ this._db.pointer, 'main', ptr, bytes.byteLength, bytes.byteLength,
35
+ capi.SQLITE_DESERIALIZE_FREEONCLOSE | capi.SQLITE_DESERIALIZE_READONLY,
36
+ );
37
+ if (rc !== 0) {
38
+ this._db.close();
39
+ throw new Error(`WasmIndexReader: couldn't open the index (sqlite3_deserialize rc=${rc})`);
40
+ }
41
+ this._dim = null;
42
+ }
43
+
44
+ /** @returns {?string} */
45
+ getMeta(key) {
46
+ const value = this._db.selectValue(`SELECT value FROM ${TABLE_META} WHERE key = ?`, [key]);
47
+ return value ?? null;
48
+ }
49
+
50
+ /** @returns {Map<string, string>} every currently-indexed path → the content hash it was
51
+ * indexed at — what a caller compares against the live files to spot stale hits. */
52
+ getFileHashes() {
53
+ const rows = this._db.exec({
54
+ sql: `SELECT path, content_hash FROM ${TABLE_FILES}`,
55
+ rowMode: 'object',
56
+ returnValue: 'resultRows',
57
+ });
58
+ return new Map(rows.map((r) => [r.path, r.content_hash]));
59
+ }
60
+
61
+ /** Same role as `VectorIndex.initVectorColumn` — required once per connection before search. */
62
+ initVectorColumn(dim, type = 'FLOAT32') {
63
+ this._db.exec({ sql: VECTOR_INIT_SQL, bind: [vectorInitOptions(dim, type)] });
64
+ this._dim = dim;
65
+ }
66
+
67
+ /**
68
+ * @param {Float32Array} queryEmbedding
69
+ * @param {number} k
70
+ * @returns {{path: string, chunkIndex: number, startOffset: number, endOffset: number, distance: number}[]}
71
+ */
72
+ search(queryEmbedding, k) {
73
+ if (this._dim === null) {
74
+ const dim = Number(this.getMeta('dimension'));
75
+ if (!dim) throw new Error('WasmIndexReader: index has no dimension recorded — is it empty?');
76
+ this.initVectorColumn(dim);
77
+ }
78
+ const queryBlob = new Uint8Array(queryEmbedding.buffer, queryEmbedding.byteOffset, queryEmbedding.byteLength);
79
+ // Plain numbers are fine here, unlike better-sqlite3 (see VectorIndex.search): the WASM
80
+ // binding binds whole JS numbers as INTEGER, which vector_full_scan requires.
81
+ const rows = this._db.exec({
82
+ sql: SEARCH_SQL,
83
+ bind: [queryBlob, overfetchFor(k), k],
84
+ rowMode: 'object',
85
+ returnValue: 'resultRows',
86
+ });
87
+ return rows.map(mapSearchRow);
88
+ }
89
+
90
+ close() {
91
+ this._db.close();
92
+ }
93
+ }
@@ -0,0 +1,13 @@
1
+ // Browser-safe entry (the explorer imports this through a Vite alias): nothing here imports
2
+ // better-sqlite3, the native sqlite-vector extension or any `node:*` module. Searching only —
3
+ // building an index stays a Node job (`mnemonad index`), since that's where the folder's files
4
+ // live on disk and where embedding the whole corpus belongs; the browser only ever embeds a
5
+ // query string.
6
+ export { default as WasmIndexReader } from './browser/WasmIndexReader.js';
7
+ export { default as BrowserSearcher } from './browser/BrowserSearcher.js';
8
+ export { default as EmbeddingProvider } from './embeddings/EmbeddingProvider.js';
9
+ export { default as StaticEmbeddingProvider } from './embeddings/StaticEmbeddingProvider.js';
10
+ export { browserModelFiles } from './embeddings/modelFiles.browser.js';
11
+ export {
12
+ DEFAULT_DB_NAME, DEFAULT_MODEL, LEGACY_DTYPE, EMBEDDER_STATIC, EMBEDDER_TRANSFORMERS, embedderOfIndex,
13
+ } from './schema.js';
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Base class for splitting one file's text content into embeddable chunks. A subclass fills
3
+ * in `chunk()`; everything else here is just the shared shape `Indexer` depends on.
4
+ *
5
+ * Deliberately separate from Mnemonad's own content-defined chunking (`FastCDC`, used for
6
+ * on-chain diffing): that one finds boundaries by content hash to maximize byte-level dedup
7
+ * between versions, which has nothing to do with what makes a good *semantic* unit to embed.
8
+ * The two chunkers operate on the same bytes for entirely unrelated reasons and should never
9
+ * be conflated.
10
+ */
11
+ export default class ChunkingStrategy {
12
+ /**
13
+ * @param {string} text - decoded file content (chunking only ever runs on text files —
14
+ * the caller is responsible for skipping binaries before this is reached).
15
+ * @returns {{startOffset: number, endOffset: number, text: string}[]} offsets are UTF-16
16
+ * code-unit offsets into `text` (i.e. plain JS string indices — `text.slice(startOffset,
17
+ * endOffset)` round-trips), not byte offsets — cheap to compute, and exact enough to
18
+ * locate a snippet back in the source file at display time.
19
+ */
20
+ chunk(_text) {
21
+ throw new Error(`${this.constructor.name}: chunk() not implemented`);
22
+ }
23
+ }
@@ -0,0 +1,69 @@
1
+ import ChunkingStrategy from './ChunkingStrategy.js';
2
+
3
+ /**
4
+ * Default chunker: fixed-size character windows with overlap, breaking on the nearest
5
+ * paragraph/sentence/word boundary before the target size rather than mid-word, when one is
6
+ * available within a reasonable lookback. Character-based rather than token-based
7
+ * deliberately — a token-accurate chunker needs a loaded tokenizer first, coupling chunking
8
+ * to whichever embedding model happens to be configured; character windows chunk perfectly
9
+ * well standalone, synchronously, with no model dependency, and 1000 characters is a
10
+ * conservative-enough stand-in for typical embedding models' token limits (roughly 4
11
+ * characters/token in English prose, so ~250 tokens — comfortably under the 256-512 token
12
+ * budget most small embedding models expect). A token-aware `ChunkingStrategy` reusing the
13
+ * configured `EmbeddingProvider`'s own tokenizer is a reasonable later swap-in — this stays
14
+ * the default because it works content-agnostically with zero setup.
15
+ */
16
+ export default class TextWindowChunkingStrategy extends ChunkingStrategy {
17
+ /**
18
+ * @param {Object} [params]
19
+ * @param {number} [params.size=1000] - target chunk size, in characters
20
+ * @param {number} [params.overlap=150] - characters of overlap between consecutive chunks
21
+ */
22
+ constructor({ size = 1000, overlap = 150 } = {}) {
23
+ super();
24
+ if (overlap >= size) {
25
+ throw new Error('TextWindowChunkingStrategy: overlap must be smaller than size');
26
+ }
27
+ this.size = size;
28
+ this.overlap = overlap;
29
+ }
30
+
31
+ chunk(text) {
32
+ if (!text) return [];
33
+ const chunks = [];
34
+ const step = this.size - this.overlap;
35
+ let start = 0;
36
+ while (start < text.length) {
37
+ let end = Math.min(start + this.size, text.length);
38
+ // Prefer breaking at a paragraph/sentence/word boundary over a hard mid-word cut —
39
+ // searched backward from `end`, but never past `start` (a very long unbroken run of
40
+ // text, e.g. a minified file, just gets a hard cut, which is fine: it wasn't going to
41
+ // chunk meaningfully either way).
42
+ if (end < text.length) {
43
+ const lookback = text.slice(start, end);
44
+ const boundary = _lastBoundary(lookback);
45
+ if (boundary > 0) end = start + boundary;
46
+ }
47
+ chunks.push({ startOffset: start, endOffset: end, text: text.slice(start, end) });
48
+ if (end >= text.length) break;
49
+ start = end - this.overlap;
50
+ }
51
+ return chunks;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Index just past the best break point found in `s` (paragraph > sentence > word), searched
57
+ * from the end backward, or -1 if none exists worth preferring over a hard cut.
58
+ * @param {string} s
59
+ * @returns {number}
60
+ */
61
+ function _lastBoundary(s) {
62
+ const paragraph = s.lastIndexOf('\n\n');
63
+ if (paragraph > s.length * 0.5) return paragraph + 2;
64
+ const sentence = Math.max(s.lastIndexOf('. '), s.lastIndexOf('.\n'));
65
+ if (sentence > s.length * 0.5) return sentence + 1;
66
+ const word = s.lastIndexOf(' ');
67
+ if (word > s.length * 0.5) return word + 1;
68
+ return -1;
69
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Base class for turning text into vectors. A subclass fills in `embed()`/`dimension`;
3
+ * everything downstream (`VectorIndex`, `Indexer`, `Searcher`) only ever talks to this
4
+ * interface, never to a specific model or runtime — swapping the default local model for an
5
+ * API-based one later needs no change outside a new subclass.
6
+ */
7
+ export default class EmbeddingProvider {
8
+ /** @returns {number} vector length this provider produces — fixed for its lifetime. */
9
+ get dimension() {
10
+ throw new Error(`${this.constructor.name}: dimension getter not implemented`);
11
+ }
12
+
13
+ /** @returns {string} identifies this provider + model in `search_index_meta`, so a later
14
+ * `search` (possibly a different process entirely) knows what it's matching against. */
15
+ get modelId() {
16
+ throw new Error(`${this.constructor.name}: modelId getter not implemented`);
17
+ }
18
+
19
+ /** @returns {?string} which kind of embedder this is ('model2vec', 'transformers'), recorded
20
+ * in the index so a later search builds the same kind (see schema.js). */
21
+ get embedder() {
22
+ return null;
23
+ }
24
+
25
+ /** @returns {?string} weight precision (e.g. 'q8', 'fp32') when the model comes in more than
26
+ * one, recorded alongside `modelId` so a searcher loads the same weights. Null when the
27
+ * provider has no such choice. */
28
+ get dtype() {
29
+ return null;
30
+ }
31
+
32
+ /**
33
+ * @param {string[]} texts
34
+ * @returns {Promise<Float32Array[]>} one vector per input text, same order, each of length
35
+ * `this.dimension`.
36
+ */
37
+ async embed(_texts) {
38
+ throw new Error(`${this.constructor.name}: embed() not implemented`);
39
+ }
40
+ }
@@ -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';