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.
- package/README.md +173 -19
- package/bin/mnemonad.js +164 -11
- package/lib/bridgeCodec.js +12 -0
- package/lib/chainClient.js +28 -7
- package/lib/commands/buildIndex.js +171 -0
- package/lib/commands/burn.js +59 -0
- package/lib/commands/compact.js +7 -2
- package/lib/commands/diff.js +25 -6
- package/lib/commands/index.js +3 -0
- package/lib/commands/info.js +42 -2
- package/lib/commands/pull.js +13 -6
- package/lib/commands/push.js +61 -7
- package/lib/commands/search.js +91 -0
- package/lib/commands/shared.js +137 -11
- package/lib/commands/watch.js +66 -12
- package/lib/passkeyBridge.js +203 -0
- package/lib/search/Indexer.js +204 -0
- package/lib/search/Searcher.js +68 -0
- package/lib/search/VectorIndex.js +225 -0
- package/lib/search/browser/BrowserSearcher.js +131 -0
- package/lib/search/browser/WasmIndexReader.js +93 -0
- package/lib/search/browser.js +13 -0
- package/lib/search/chunking/ChunkingStrategy.js +23 -0
- package/lib/search/chunking/TextWindowChunkingStrategy.js +69 -0
- package/lib/search/embeddings/EmbeddingProvider.js +40 -0
- package/lib/search/embeddings/StaticEmbeddingProvider.js +180 -0
- package/lib/search/embeddings/modelFiles.browser.js +28 -0
- package/lib/search/embeddings/modelFiles.node.js +41 -0
- package/lib/search/embeddings/modelFiles.shared.js +44 -0
- package/lib/search/index.js +18 -0
- package/lib/search/providers.js +153 -0
- package/lib/search/schema.js +103 -0
- package/mnemonad.config.js +19 -5
- package/package.json +18 -3
|
@@ -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
|
+
}
|
package/mnemonad.config.js
CHANGED
|
@@ -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
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
}
|