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,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,8 +1,16 @@
1
1
  {
2
2
  "name": "mnemonad-cli",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "CLI to sync local folders to versioned, diffed on-chain streams on Monad, backed by Mnemonad + monadsync.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/jeka911/mnemonad.git",
8
+ "directory": "cli"
9
+ },
5
10
  "type": "module",
11
+ "engines": {
12
+ "node": ">=22"
13
+ },
6
14
  "license": "AGPL-3.0",
7
15
  "bin": {
8
16
  "mnemonad": "./bin/mnemonad.js"
@@ -14,14 +22,21 @@
14
22
  "README.md"
15
23
  ],
16
24
  "dependencies": {
25
+ "@huggingface/tokenizers": "^0.2.0",
26
+ "@sqliteai/sqlite-vector": "^1.1.2",
27
+ "better-sqlite3": "^13.0.3",
17
28
  "mnemonad": "^0.0.1",
18
29
  "monadsync": "^0.0.1",
19
- "viem": "^2.56.3"
30
+ "open": "^11.0.4",
31
+ "viem": "^2.56.3",
32
+ "ws": "^8.22.0"
20
33
  },
21
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",
22
37
  "vitest": "^4.1.11"
23
38
  },
24
39
  "scripts": {
25
- "test": "vitest run ./test/*.test.js"
40
+ "test": "vitest run ./test/*.test.js ./test/search/*.test.js"
26
41
  }
27
42
  }