mnemonad-cli 0.2.0 → 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 +113 -9
- package/bin/mnemonad.js +111 -5
- package/lib/bridgeCodec.js +12 -0
- package/lib/chainClient.js +21 -7
- package/lib/commands/buildIndex.js +171 -0
- package/lib/commands/burn.js +1 -1
- package/lib/commands/compact.js +1 -1
- package/lib/commands/diff.js +1 -1
- package/lib/commands/index.js +2 -0
- package/lib/commands/info.js +1 -1
- package/lib/commands/pull.js +1 -1
- package/lib/commands/push.js +44 -1
- package/lib/commands/search.js +91 -0
- package/lib/commands/shared.js +3 -2
- package/lib/commands/watch.js +58 -9
- 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 +13 -3
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import {
|
|
3
|
+
Indexer, TextWindowChunkingStrategy, VectorIndex, DEFAULT_DB_NAME, DEFAULT_MODEL,
|
|
4
|
+
createProvider, resolveModelName, embedderOfIndex, ModelMismatchError, MissingExtensionError,
|
|
5
|
+
} from '../search/index.js';
|
|
6
|
+
import { FSFolder } from '../FSFolder.js';
|
|
7
|
+
import { resolvePath, DEFAULT_FS_EXCLUDES, userError } from './shared.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The vector index, shared by `mnemonad index` and by `push --index` / `watch --index`.
|
|
11
|
+
* Kept in one place so all three agree on which file it is, which files it covers, and how
|
|
12
|
+
* they're chunked and embedded — an index built by `push --index` is the same index
|
|
13
|
+
* `mnemonad index` would have built.
|
|
14
|
+
*
|
|
15
|
+
* The default filename is at the folder root and not dot-prefixed: unlike `.mnemonad`, it's
|
|
16
|
+
* meant to be pushed and pulled like any other file, so a pull is searchable immediately.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** @returns {string} the index's filename (`--db`, default `search_index.db`). */
|
|
20
|
+
export function indexDbName(args) {
|
|
21
|
+
return args.searchDb || DEFAULT_DB_NAME;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @returns {string[]} what the index skips: the push's own excludes, plus the index file.
|
|
25
|
+
* The index file itself must never be walked as content to embed — explicit, not left to
|
|
26
|
+
* chance: a SQLite file's own header/padding bytes are plausible-looking text in places,
|
|
27
|
+
* so relying on the binary-content sniff alone to skip it would be fragile. */
|
|
28
|
+
export function indexExcludes(args) {
|
|
29
|
+
const excludes = [...DEFAULT_FS_EXCLUDES, indexDbName(args)];
|
|
30
|
+
if (args.exclude) excludes.push(...args.exclude.split(',').map((s) => s.trim()));
|
|
31
|
+
return excludes;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function chunkingFrom(args) {
|
|
35
|
+
return new TextWindowChunkingStrategy({
|
|
36
|
+
size: args.chunkSize ?? 1000,
|
|
37
|
+
overlap: args.chunkOverlap ?? 150,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Which model to index `destPath` with. An explicit `--model` wins (an alias like `minilm`, or
|
|
43
|
+
* a full id). Without one, an existing index keeps the model it was built with — so re-indexing
|
|
44
|
+
* a folder someone indexed with MiniLM just works — and only a new index (or `--rebuild`)
|
|
45
|
+
* gets the built-in default.
|
|
46
|
+
*
|
|
47
|
+
* @returns {{model: string, embedder: ?string, dtype: ?string}}
|
|
48
|
+
*/
|
|
49
|
+
export function chooseModel(destPath, args) {
|
|
50
|
+
if (args.model) return { model: resolveModelName(args.model), embedder: null, dtype: null };
|
|
51
|
+
const meta = args.rebuild ? null : VectorIndex.readMeta(join(destPath, indexDbName(args)));
|
|
52
|
+
if (meta?.model) {
|
|
53
|
+
return { model: meta.model, embedder: embedderOfIndex(meta), dtype: meta.dtype || null };
|
|
54
|
+
}
|
|
55
|
+
return { model: DEFAULT_MODEL, embedder: null, dtype: null };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* An Indexer configured from the CLI flags. Hold on to it across runs (as `watch --index`
|
|
60
|
+
* does) and the embedding model loads once, on first use, instead of on every run.
|
|
61
|
+
* Rejects straight away on bad chunking flags or a missing extension, before anything is
|
|
62
|
+
* read or written.
|
|
63
|
+
*/
|
|
64
|
+
export async function makeIndexer(destPath, args) {
|
|
65
|
+
const chunkingStrategy = chunkingFrom(args);
|
|
66
|
+
const { model, embedder, dtype } = chooseModel(destPath, args);
|
|
67
|
+
let embeddingProvider;
|
|
68
|
+
try {
|
|
69
|
+
embeddingProvider = await createProvider({ model, ...(embedder ? { embedder } : {}), dtype });
|
|
70
|
+
} catch (err) {
|
|
71
|
+
throw explainIndexError(err, destPath, args);
|
|
72
|
+
}
|
|
73
|
+
return new Indexer({ embeddingProvider, chunkingStrategy });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Builds or updates `destPath`'s index, printing the same summary `mnemonad index` does.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} destPath - resolved folder path
|
|
80
|
+
* @param {Object} args - parsed CLI args (`rebuild` starts the index over)
|
|
81
|
+
* @param {Object} [opts]
|
|
82
|
+
* @param {Indexer} [opts.indexer] - reuse one (and its loaded model) across calls
|
|
83
|
+
* @param {boolean} [opts.quiet] - one summary line instead of the full report
|
|
84
|
+
* @param {boolean} [opts.rebuild] - overrides `args.rebuild` (watch rebuilds only once)
|
|
85
|
+
* @returns {Promise<{filesChanged: number, filesSkipped: number, filesRemoved: number, chunksAdded: number}>}
|
|
86
|
+
*/
|
|
87
|
+
export async function updateIndex(destPath, args, { indexer = null, quiet = false, rebuild = args.rebuild } = {}) {
|
|
88
|
+
indexer = indexer || await makeIndexer(destPath, { ...args, rebuild });
|
|
89
|
+
const dbName = indexDbName(args);
|
|
90
|
+
const fsFolder = new FSFolder(destPath, indexExcludes(args));
|
|
91
|
+
|
|
92
|
+
if (!quiet) {
|
|
93
|
+
console.log(' embedding model:', indexer.embeddingProvider.modelId, '(downloads and caches on first use)');
|
|
94
|
+
if (rebuild) console.log(` rebuilding ${dbName} from scratch`);
|
|
95
|
+
console.log(' scanning for changed files...');
|
|
96
|
+
}
|
|
97
|
+
let result;
|
|
98
|
+
try {
|
|
99
|
+
result = await indexer.index(fsFolder, join(destPath, dbName), { rebuild: !!rebuild });
|
|
100
|
+
} catch (err) {
|
|
101
|
+
throw explainIndexError(err, destPath, args);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (quiet) {
|
|
105
|
+
const changed = result.filesChanged + result.filesRemoved;
|
|
106
|
+
console.log(changed
|
|
107
|
+
? ` index: ${result.filesChanged} file(s) re-embedded, ${result.filesRemoved} removed (${result.chunksAdded} chunks)`
|
|
108
|
+
: ' index: up to date');
|
|
109
|
+
} else {
|
|
110
|
+
console.log(' files changed:', result.filesChanged);
|
|
111
|
+
console.log(' files skipped:', result.filesSkipped, '(content unchanged since last index)');
|
|
112
|
+
console.log(' files removed:', result.filesRemoved);
|
|
113
|
+
console.log(' chunks added: ', result.chunksAdded);
|
|
114
|
+
}
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* How far `destPath`'s index has drifted from its files, without updating it — no model is
|
|
120
|
+
* loaded (not even the extension looked up) and the index is opened read-only (see
|
|
121
|
+
* Indexer.status()). Null when there's no index.
|
|
122
|
+
*
|
|
123
|
+
* @returns {Promise<?{changed: string[], added: string[], removed: string[], total: number}>}
|
|
124
|
+
*/
|
|
125
|
+
export async function checkIndex(destPath, args) {
|
|
126
|
+
const fsFolder = new FSFolder(destPath, indexExcludes(args));
|
|
127
|
+
// Chunking decides which files count (an empty one is never indexed), so it's configured
|
|
128
|
+
// exactly as for a real run; status() never embeds, so no provider is needed.
|
|
129
|
+
const indexer = new Indexer({ embeddingProvider: null, chunkingStrategy: chunkingFrom(args) });
|
|
130
|
+
const status = await indexer.status(fsFolder, join(destPath, indexDbName(args)));
|
|
131
|
+
if (!status) return null;
|
|
132
|
+
return { ...status, total: status.changed.length + status.added.length + status.removed.length };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Turns the two errors a user can act on into CLI messages that say what to do. */
|
|
136
|
+
function explainIndexError(err, destPath, args) {
|
|
137
|
+
if (err instanceof MissingExtensionError) {
|
|
138
|
+
const lines = [err.message];
|
|
139
|
+
if (!args.model) {
|
|
140
|
+
lines.push(
|
|
141
|
+
' Or rebuild the index with the built-in model instead:',
|
|
142
|
+
` mnemonad index ${destPath} --rebuild`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return userError(lines.join('\n'));
|
|
146
|
+
}
|
|
147
|
+
if (err instanceof ModelMismatchError) {
|
|
148
|
+
return userError(
|
|
149
|
+
`${indexDbName(args)} was built with ${err.indexModel}, but this run asked for ${err.providerModel} —\n` +
|
|
150
|
+
' one index can\'t mix two models. Either drop --model to keep using the index\'s own model, or\n' +
|
|
151
|
+
` add --rebuild to re-embed every file with ${err.providerModel} (the next push then uploads the\n` +
|
|
152
|
+
' whole index again).'
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return err;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** `mnemonad index <path>` */
|
|
159
|
+
export async function buildIndex(args) {
|
|
160
|
+
const destPath = resolvePath(args.path, { mustExist: true });
|
|
161
|
+
const result = await updateIndex(destPath, args);
|
|
162
|
+
|
|
163
|
+
if (result.filesChanged > 0 || result.filesRemoved > 0) {
|
|
164
|
+
// Said explicitly rather than left implicit: unlike `.mnemonad`, this file is not
|
|
165
|
+
// dot-prefixed, so the very next `push` from this folder includes it automatically —
|
|
166
|
+
// worth knowing before that push happens, not after.
|
|
167
|
+
console.log(`\n note: ${indexDbName(args)} will be included in the next \`mnemonad push\` from this folder`);
|
|
168
|
+
} else {
|
|
169
|
+
console.log('\n no changes — nothing new to push');
|
|
170
|
+
}
|
|
171
|
+
}
|
package/lib/commands/burn.js
CHANGED
package/lib/commands/compact.js
CHANGED
|
@@ -34,7 +34,7 @@ export async function compact(args) {
|
|
|
34
34
|
const counts = await countTree(fsFolder);
|
|
35
35
|
console.log(' scanning...', counts.files, 'files,', counts.folders, 'folders');
|
|
36
36
|
|
|
37
|
-
const client = makeChainClient(args);
|
|
37
|
+
const client = await makeChainClient(args);
|
|
38
38
|
requireWalletClient(client);
|
|
39
39
|
|
|
40
40
|
// No `password` at construction — see shared.js's unlockStream() for why.
|
package/lib/commands/diff.js
CHANGED
package/lib/commands/index.js
CHANGED
package/lib/commands/info.js
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from './shared.js';
|
|
15
15
|
|
|
16
16
|
export async function info(args) {
|
|
17
|
-
const client = makeChainClient(args);
|
|
17
|
+
const client = await makeChainClient(args);
|
|
18
18
|
|
|
19
19
|
console.log(' chain: ', args.chain);
|
|
20
20
|
console.log(' your wallet: ', client.account?.address || '(none)');
|
package/lib/commands/pull.js
CHANGED
package/lib/commands/push.js
CHANGED
|
@@ -18,7 +18,9 @@ import {
|
|
|
18
18
|
formatStreamId,
|
|
19
19
|
streamIdsEqual,
|
|
20
20
|
discoverStreamId,
|
|
21
|
+
userError,
|
|
21
22
|
} from './shared.js';
|
|
23
|
+
import { updateIndex, checkIndex, indexDbName } from './buildIndex.js';
|
|
22
24
|
|
|
23
25
|
export async function push(args) {
|
|
24
26
|
const destPath = resolvePath(args.path, { mustExist: true });
|
|
@@ -29,6 +31,23 @@ export async function push(args) {
|
|
|
29
31
|
await discoverStreamId(args, destPath);
|
|
30
32
|
let streamId = args.streamId;
|
|
31
33
|
|
|
34
|
+
// Before anything is scanned or signed: the push below then picks the updated index up
|
|
35
|
+
// like any other file. A failure stops the push outright — a push that silently went out
|
|
36
|
+
// without the index asked for would leave the stream unsearchable with no sign of why.
|
|
37
|
+
if (args.rebuild && !args.index) {
|
|
38
|
+
throw userError('--rebuild only applies with --index (it starts the search index over)');
|
|
39
|
+
}
|
|
40
|
+
if (args.index) {
|
|
41
|
+
console.log(args.rebuild ? ' rebuilding search index...' : ' updating search index...');
|
|
42
|
+
try {
|
|
43
|
+
await updateIndex(destPath, args, { quiet: true });
|
|
44
|
+
} catch (err) {
|
|
45
|
+
throw userError(`indexing failed, so nothing was pushed: ${err.message}`);
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
await warnIfIndexStale(destPath, args);
|
|
49
|
+
}
|
|
50
|
+
|
|
32
51
|
const fsFolder = new FSFolder(destPath, makeExcludes(args));
|
|
33
52
|
|
|
34
53
|
const counts = await countTree(fsFolder);
|
|
@@ -45,7 +64,7 @@ export async function push(args) {
|
|
|
45
64
|
}
|
|
46
65
|
}
|
|
47
66
|
|
|
48
|
-
const client = makeChainClient(args);
|
|
67
|
+
const client = await makeChainClient(args);
|
|
49
68
|
requireWalletClient(client);
|
|
50
69
|
|
|
51
70
|
const baseParams = {
|
|
@@ -136,3 +155,27 @@ export async function push(args) {
|
|
|
136
155
|
await writeManifest(destPath, { streamId: formatStreamId(streamId), version: result.version, files: localFiles });
|
|
137
156
|
}
|
|
138
157
|
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A folder that already carries an index, pushed without `--index` after its files changed,
|
|
161
|
+
* would publish an index that no longer matches them — searches keep returning the old text's
|
|
162
|
+
* offsets. Cheap to catch (a hash per file, no model load, the index opened read-only) and
|
|
163
|
+
* worth a line; never a reason to fail the push itself.
|
|
164
|
+
*/
|
|
165
|
+
async function warnIfIndexStale(destPath, args) {
|
|
166
|
+
let status;
|
|
167
|
+
try {
|
|
168
|
+
status = await checkIndex(destPath, args);
|
|
169
|
+
} catch {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (!status || status.total === 0) return;
|
|
173
|
+
const parts = [];
|
|
174
|
+
if (status.changed.length) parts.push(`${status.changed.length} changed`);
|
|
175
|
+
if (status.added.length) parts.push(`${status.added.length} new`);
|
|
176
|
+
if (status.removed.length) parts.push(`${status.removed.length} removed`);
|
|
177
|
+
console.log(
|
|
178
|
+
` note: ${indexDbName(args)} is out of date (${parts.join(', ')} file(s) since it was built).\n` +
|
|
179
|
+
' Push with --index to update it first, or run `mnemonad index <path>`.'
|
|
180
|
+
);
|
|
181
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import {
|
|
3
|
+
Searcher, VectorIndex, DEFAULT_DB_NAME, createProvider, resolveModelName, MissingExtensionError,
|
|
4
|
+
} from '../search/index.js';
|
|
5
|
+
import { resolvePath, folderExists } from './shared.js';
|
|
6
|
+
|
|
7
|
+
export async function search(args) {
|
|
8
|
+
if (!args.query) {
|
|
9
|
+
throw userError('a search query is required (e.g. `mnemonad search /path/to/folder "how does chunking work"`)');
|
|
10
|
+
}
|
|
11
|
+
const destPath = resolvePath(args.path, { mustExist: true });
|
|
12
|
+
const dbName = args.searchDb || DEFAULT_DB_NAME;
|
|
13
|
+
const dbPath = join(destPath, dbName);
|
|
14
|
+
|
|
15
|
+
if (!folderExists(destPath) || !(await _fileExists(dbPath))) {
|
|
16
|
+
throw userError(
|
|
17
|
+
`no ${dbName} found in ${destPath}\n` +
|
|
18
|
+
' Run `mnemonad index <path>` first — either yours, or whoever pushed this stream\'s\n' +
|
|
19
|
+
' (a pull already brings theirs down with the rest of the folder).'
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The query has to be embedded by the model that built the index — which the index records,
|
|
24
|
+
// so there's nothing to choose. An explicit --model that disagrees is a mistake worth
|
|
25
|
+
// naming rather than a preference to honour.
|
|
26
|
+
const indexModel = VectorIndex.readMeta(dbPath)?.model;
|
|
27
|
+
if (args.model && indexModel && resolveModelName(args.model) !== indexModel) {
|
|
28
|
+
throw userError(
|
|
29
|
+
`${dbName} was built with ${indexModel}; a search has to use the same model — drop --model\n` +
|
|
30
|
+
' (search always uses the index\'s own), or rebuild the index with the other one:\n' +
|
|
31
|
+
` mnemonad index ${destPath} --model ${args.model} --rebuild`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const searcher = new Searcher({ createProvider });
|
|
36
|
+
const k = args.limit ? Number(args.limit) : 5;
|
|
37
|
+
let hits;
|
|
38
|
+
try {
|
|
39
|
+
hits = await searcher.search(dbPath, args.query, k);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (err instanceof MissingExtensionError) {
|
|
42
|
+
throw userError(
|
|
43
|
+
`this folder's index was built with ${err.model}, which ${err.message.slice(err.message.indexOf('needs'))}\n` +
|
|
44
|
+
' Or rebuild the index with the built-in model:\n' +
|
|
45
|
+
` mnemonad index ${destPath} --rebuild`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (hits.length === 0) {
|
|
52
|
+
console.log(' no matches');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const [i, hit] of hits.entries()) {
|
|
57
|
+
console.log(`\n${i + 1}. ${hit.path} (chunk ${hit.chunkIndex}, distance ${hit.distance.toFixed(4)})`);
|
|
58
|
+
const snippet = await _readSnippet(destPath, hit);
|
|
59
|
+
if (snippet) console.log(' ' + snippet.replace(/\s+/g, ' ').slice(0, 200));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function _fileExists(path) {
|
|
64
|
+
try {
|
|
65
|
+
const { stat } = await import('node:fs/promises');
|
|
66
|
+
const s = await stat(path);
|
|
67
|
+
return s.isFile();
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Re-reads the hit's snippet straight from the already-pulled source file — the index
|
|
74
|
+
* deliberately doesn't store chunk text itself (see ../search/VectorIndex.js),
|
|
75
|
+
* so this is the one place that cost is paid, and only for hits actually shown. */
|
|
76
|
+
async function _readSnippet(destPath, hit) {
|
|
77
|
+
try {
|
|
78
|
+
const { readFile } = await import('node:fs/promises');
|
|
79
|
+
const bytes = await readFile(join(destPath, hit.path));
|
|
80
|
+
const text = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
|
81
|
+
return text.slice(hit.startOffset, hit.endOffset);
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function userError(message) {
|
|
88
|
+
const err = new Error(message);
|
|
89
|
+
err._isUserError = true;
|
|
90
|
+
return err;
|
|
91
|
+
}
|
package/lib/commands/shared.js
CHANGED
|
@@ -12,7 +12,7 @@ import { DoubleSync as SyncEngine, CDCStore, DoubleSyncSnapshot as SyncSnapshot
|
|
|
12
12
|
import { FSFolder } from '../FSFolder.js';
|
|
13
13
|
import { createPresignProvider } from '../presignProvider.js';
|
|
14
14
|
|
|
15
|
-
const DEFAULT_FS_EXCLUDES = ['node_modules', '.git', '.env', '.DS_Store', '.mnemonad', '.claude', 'pnpm-lock.yaml', 'package-lock.json'];
|
|
15
|
+
export const DEFAULT_FS_EXCLUDES = ['node_modules', '.git', '.env', '.DS_Store', '.mnemonad', '.claude', 'pnpm-lock.yaml', 'package-lock.json'];
|
|
16
16
|
|
|
17
17
|
export function makeExcludes(args) {
|
|
18
18
|
if (!args.exclude) return undefined;
|
|
@@ -305,7 +305,8 @@ export async function unlockStream(mn, credentials = {}) {
|
|
|
305
305
|
return true;
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
-
|
|
308
|
+
/** An error the CLI prints as a plain message (no stack trace) — see bin/mnemonad.js. */
|
|
309
|
+
export function userError(message) {
|
|
309
310
|
const err = new Error(message);
|
|
310
311
|
err._isUserError = true;
|
|
311
312
|
return err;
|
package/lib/commands/watch.js
CHANGED
|
@@ -16,7 +16,9 @@ import {
|
|
|
16
16
|
resolveOffloadParams,
|
|
17
17
|
formatStreamId,
|
|
18
18
|
discoverStreamId,
|
|
19
|
+
userError,
|
|
19
20
|
} from './shared.js';
|
|
21
|
+
import { makeIndexer, updateIndex } from './buildIndex.js';
|
|
20
22
|
|
|
21
23
|
function timestamp() {
|
|
22
24
|
return new Date().toTimeString().slice(0, 8);
|
|
@@ -40,9 +42,20 @@ export async function watch(args) {
|
|
|
40
42
|
const pushEnabled = !args.pullOnly;
|
|
41
43
|
const pullEnabled = !args.pushOnly;
|
|
42
44
|
|
|
45
|
+
if (args.index && !pushEnabled) {
|
|
46
|
+
throw userError('--index only applies when watch pushes — drop --pull-only to use it');
|
|
47
|
+
}
|
|
48
|
+
if (args.rebuild && !args.index) {
|
|
49
|
+
throw userError('--rebuild only applies with --index (it starts the search index over)');
|
|
50
|
+
}
|
|
51
|
+
// One indexer for the whole session: the embedding model loads on the first change and
|
|
52
|
+
// then stays in memory, so every later re-index costs only the files that changed.
|
|
53
|
+
// Built now so bad chunking flags or a missing extension fail before watching starts.
|
|
54
|
+
const indexer = args.index ? await makeIndexer(cwd, args) : null;
|
|
55
|
+
|
|
43
56
|
const excludes = makeExcludes(args);
|
|
44
57
|
|
|
45
|
-
const client = makeChainClient(args);
|
|
58
|
+
const client = await makeChainClient(args);
|
|
46
59
|
if (pushEnabled) requireWalletClient(client);
|
|
47
60
|
|
|
48
61
|
// Only a push-enabled session needs write-side offload capability (pinata-jwt or
|
|
@@ -79,6 +92,7 @@ export async function watch(args) {
|
|
|
79
92
|
console.log(` remote version: ${remoteVersion}`);
|
|
80
93
|
console.log(` push: ${pushEnabled ? `enabled (debounce ${debounceMs}ms)` : 'disabled'}`);
|
|
81
94
|
console.log(` pull: ${pullEnabled ? `enabled (poll every ${pollIntervalMs / 1000}s)` : 'disabled'}`);
|
|
95
|
+
if (indexer) console.log(' index: updated before every push');
|
|
82
96
|
console.log('press Ctrl+C to stop\n');
|
|
83
97
|
|
|
84
98
|
async function runPush() {
|
|
@@ -88,13 +102,34 @@ export async function watch(args) {
|
|
|
88
102
|
|
|
89
103
|
pushInFlight = true;
|
|
90
104
|
try {
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
105
|
+
// Before the push, so the push carries it. The index file's own write then fires
|
|
106
|
+
// the watcher again, but by then lastSyncedHash (taken after indexing, below)
|
|
107
|
+
// already includes it, so that second pass finds nothing new and doesn't push.
|
|
108
|
+
// A failed index skips the push rather than publishing a stale index; the next
|
|
109
|
+
// change retries both.
|
|
110
|
+
let hashToSync = newHash;
|
|
111
|
+
if (indexer) {
|
|
112
|
+
try {
|
|
113
|
+
// Never a rebuild here — `--rebuild` applies once, to the startup pass below.
|
|
114
|
+
await updateIndex(cwd, args, { indexer, quiet: true, rebuild: false });
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.error(`[${timestamp()}] index update failed, not pushing:`, err.message);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
hashToSync = await localTreeHash(cwd);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Best-effort race guard, not an atomic one. Mnemonad's contract *does* check an
|
|
123
|
+
// `expectedLength` atomically (Mnemonad.sol's `_checkLength`/`LengthMismatch`) —
|
|
124
|
+
// that closes the index race outright: two writers can never land at the same
|
|
125
|
+
// position, one just reverts. What it can't check is content: it stores opaque
|
|
126
|
+
// bytes, with no way to verify a diff patch is actually consistent with the real
|
|
127
|
+
// previous item. Re-checking here narrows the separate window where a concurrent
|
|
128
|
+
// push (another `watch` session, or a manual push) lands at the *correct* index
|
|
129
|
+
// but MonadSync still builds its diff against a snapshot the chain has since
|
|
130
|
+
// moved past — the same failure mode monadsync's own snapshot-recovery tests
|
|
131
|
+
// exercise. Confirmed live as a real corruption, not just theoretical — see
|
|
132
|
+
// monadsync/README.md's "Repairing a corrupt chain".
|
|
98
133
|
mn.reInitialize();
|
|
99
134
|
await mn.initialize();
|
|
100
135
|
if (mn.length !== remoteVersion) {
|
|
@@ -107,7 +142,7 @@ export async function watch(args) {
|
|
|
107
142
|
const fsFolder = new FSFolder(cwd, excludes);
|
|
108
143
|
console.log(`[${timestamp()}] change detected — pushing...`);
|
|
109
144
|
const result = await monadSync.push(fsFolder);
|
|
110
|
-
lastSyncedHash =
|
|
145
|
+
lastSyncedHash = hashToSync;
|
|
111
146
|
remoteVersion = result.version;
|
|
112
147
|
console.log(`[${timestamp()}] pushed version ${result.version}`);
|
|
113
148
|
} catch (err) {
|
|
@@ -169,6 +204,20 @@ export async function watch(args) {
|
|
|
169
204
|
|
|
170
205
|
const poller = setInterval(runPoll, pollIntervalMs);
|
|
171
206
|
|
|
207
|
+
// An index that's already out of date when the session starts (files edited, or indexed
|
|
208
|
+
// for the first time) would otherwise only catch up on the next local change.
|
|
209
|
+
if (indexer) {
|
|
210
|
+
try {
|
|
211
|
+
const result = await updateIndex(cwd, args, { indexer, quiet: true, rebuild: !!args.rebuild });
|
|
212
|
+
if (result.filesChanged || result.filesRemoved) {
|
|
213
|
+
console.log(`[${timestamp()}] index was out of date — pushing the update`);
|
|
214
|
+
debounceTimer = setTimeout(runPush, 0);
|
|
215
|
+
}
|
|
216
|
+
} catch (err) {
|
|
217
|
+
console.error(`[${timestamp()}] index update failed:`, err.message);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
172
221
|
let shutdownResolve;
|
|
173
222
|
const shutdownPromise = new Promise((resolve) => {
|
|
174
223
|
shutdownResolve = resolve;
|