oh-my-second-brain 0.3.0 → 0.6.2
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/ACKNOWLEDGMENTS.md +8 -0
- package/CHANGELOG-assets.md +10 -0
- package/CHANGELOG-cli.md +11 -0
- package/CHANGELOG-kernel.md +31 -0
- package/CHANGELOG-mcp.md +12 -0
- package/CHANGELOG-vendors.md +6 -0
- package/CHANGELOG.md +35 -0
- package/assets/hermes-manifest.json +1 -1
- package/assets/skills/doctor/SKILL.md +2 -2
- package/assets/skills/search/SKILL.md +17 -1
- package/dist/cli/args.d.ts +2 -0
- package/dist/cli/args.js +13 -0
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/host-commands.js +2 -1
- package/dist/cli/host-commands.js.map +1 -1
- package/dist/cli/oms.d.ts +1 -1
- package/dist/cli/oms.js +26 -3
- package/dist/cli/oms.js.map +1 -1
- package/dist/cli/semantic-args.js +102 -3
- package/dist/cli/semantic-args.js.map +1 -1
- package/dist/cli/semantic.js +30 -6
- package/dist/cli/semantic.js.map +1 -1
- package/dist/cli/setup-command.d.ts +16 -0
- package/dist/cli/setup-command.js +50 -1
- package/dist/cli/setup-command.js.map +1 -1
- package/dist/kernel/contracts/index.d.ts +144 -0
- package/dist/kernel/contracts/index.js +401 -0
- package/dist/kernel/contracts/index.js.map +1 -0
- package/dist/kernel/conventions/note-exclude.d.ts +14 -0
- package/dist/kernel/conventions/note-exclude.js +95 -0
- package/dist/kernel/conventions/note-exclude.js.map +1 -0
- package/dist/kernel/engine/assemble.d.ts +22 -1
- package/dist/kernel/engine/assemble.js +202 -19
- package/dist/kernel/engine/assemble.js.map +1 -1
- package/dist/kernel/engine/axes/store.d.ts +90 -0
- package/dist/kernel/engine/axes/store.js +422 -0
- package/dist/kernel/engine/axes/store.js.map +1 -0
- package/dist/kernel/engine/conventions/vault-lint.d.ts +7 -2
- package/dist/kernel/engine/conventions/vault-lint.js +7 -21
- package/dist/kernel/engine/conventions/vault-lint.js.map +1 -1
- package/dist/kernel/engine/embed/identity.d.ts +14 -4
- package/dist/kernel/engine/embed/identity.js +33 -1
- package/dist/kernel/engine/embed/identity.js.map +1 -1
- package/dist/kernel/engine/embed/model.d.ts +134 -0
- package/dist/kernel/engine/embed/model.js +302 -0
- package/dist/kernel/engine/embed/model.js.map +1 -0
- package/dist/kernel/engine/embed/provider.d.ts +36 -5
- package/dist/kernel/engine/embed/provider.js +240 -103
- package/dist/kernel/engine/embed/provider.js.map +1 -1
- package/dist/kernel/engine/embed/store.d.ts +5 -1
- package/dist/kernel/engine/embed/store.js +136 -26
- package/dist/kernel/engine/embed/store.js.map +1 -1
- package/dist/kernel/engine/embed/sync.d.ts +53 -0
- package/dist/kernel/engine/embed/sync.js +489 -59
- package/dist/kernel/engine/embed/sync.js.map +1 -1
- package/dist/kernel/engine/graph/builder.d.ts +9 -5
- package/dist/kernel/engine/graph/builder.js +225 -82
- package/dist/kernel/engine/graph/builder.js.map +1 -1
- package/dist/kernel/engine/graph/node.d.ts +49 -1
- package/dist/kernel/engine/graph/node.js +205 -0
- package/dist/kernel/engine/graph/node.js.map +1 -1
- package/dist/kernel/engine/mcp/facade.d.ts +21 -1
- package/dist/kernel/engine/mcp/facade.js +523 -28
- package/dist/kernel/engine/mcp/facade.js.map +1 -1
- package/dist/kernel/engine/mcp/query-mapper.d.ts +27 -2
- package/dist/kernel/engine/mcp/query-mapper.js +157 -12
- package/dist/kernel/engine/mcp/query-mapper.js.map +1 -1
- package/dist/kernel/engine/mcp/types.d.ts +64 -2
- package/dist/kernel/engine/retrieval/dispatcher.d.ts +22 -0
- package/dist/kernel/engine/retrieval/dispatcher.js +84 -8
- package/dist/kernel/engine/retrieval/dispatcher.js.map +1 -1
- package/dist/kernel/engine/retrieval/index.d.ts +2 -1
- package/dist/kernel/engine/retrieval/index.js +1 -1
- package/dist/kernel/engine/retrieval/index.js.map +1 -1
- package/dist/kernel/engine/retrieval/reranker.d.ts +82 -4
- package/dist/kernel/engine/retrieval/reranker.js +178 -3
- package/dist/kernel/engine/retrieval/reranker.js.map +1 -1
- package/dist/kernel/engine/retrieval/rrf.d.ts +10 -2
- package/dist/kernel/engine/retrieval/rrf.js +39 -16
- package/dist/kernel/engine/retrieval/rrf.js.map +1 -1
- package/dist/kernel/engine/tracer.d.ts +13 -0
- package/dist/kernel/engine/tracer.js +63 -6
- package/dist/kernel/engine/tracer.js.map +1 -1
- package/dist/kernel/graph/cache.js +174 -23
- package/dist/kernel/graph/cache.js.map +1 -1
- package/dist/kernel/index.d.ts +2 -0
- package/dist/kernel/index.js +2 -0
- package/dist/kernel/index.js.map +1 -1
- package/dist/kernel/measurement/no-default-contract.d.ts +17 -0
- package/dist/kernel/measurement/no-default-contract.js +84 -0
- package/dist/kernel/measurement/no-default-contract.js.map +1 -0
- package/dist/kernel/searchbackend/engine-search-backend.js +93 -7
- package/dist/kernel/searchbackend/engine-search-backend.js.map +1 -1
- package/dist/kernel/searchbackend/search-backend.d.ts +11 -1
- package/dist/kernel/searchbackend/search-backend.js +66 -5
- package/dist/kernel/searchbackend/search-backend.js.map +1 -1
- package/dist/kernel/semantic/semantic-engine.js +31 -5
- package/dist/kernel/semantic/semantic-engine.js.map +1 -1
- package/dist/kernel/semantic/semantic-retrieve-args.d.ts +19 -2
- package/dist/kernel/semantic/semantic-retrieve-args.js +81 -7
- package/dist/kernel/semantic/semantic-retrieve-args.js.map +1 -1
- package/dist/kernel/update/update.d.ts +7 -0
- package/dist/kernel/update/update.js +106 -8
- package/dist/kernel/update/update.js.map +1 -1
- package/dist/mcp/server.js +84 -34
- package/dist/mcp/server.js.map +1 -1
- package/package.json +3 -2
|
@@ -6,24 +6,32 @@
|
|
|
6
6
|
* - embed=false is a lex-only sync: it MUST NOT fabricate vectors.
|
|
7
7
|
* - Fingerprint mismatch fails fast by default; destructive rebuild only with explicit force.
|
|
8
8
|
*/
|
|
9
|
+
import { closeSync, existsSync, fsyncSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, } from "node:fs";
|
|
9
10
|
import { readdir, readFile } from "node:fs/promises";
|
|
10
11
|
import path from "node:path";
|
|
11
|
-
import Database from "better-sqlite3";
|
|
12
12
|
import { chunkDocument } from "./chunker.js";
|
|
13
13
|
import { requireRealEmbeddingProvider } from "./provider.js";
|
|
14
|
-
import {
|
|
14
|
+
import { openEngineStore, openEngineStoreCore } from "./store.js";
|
|
15
15
|
import { makeEmbeddingIdentity } from "./identity.js";
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
17
17
|
// Internal vault walker
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
19
19
|
const SKIP_DIRS = new Set(["node_modules", ".git", ".oms"]);
|
|
20
|
+
function isEnoent(error) {
|
|
21
|
+
return typeof error === "object" && error !== null && "code" in error &&
|
|
22
|
+
error.code === "ENOENT";
|
|
23
|
+
}
|
|
20
24
|
export async function* walkMarkdown(dir, base) {
|
|
21
25
|
let entries;
|
|
22
26
|
try {
|
|
23
27
|
entries = await readdir(dir, { withFileTypes: true });
|
|
24
28
|
}
|
|
25
|
-
catch {
|
|
26
|
-
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (isEnoent(error))
|
|
31
|
+
return;
|
|
32
|
+
throw new Error(`Unable to scan vault directory "${dir}": ${error instanceof Error ? error.message : String(error)}`, {
|
|
33
|
+
cause: error,
|
|
34
|
+
});
|
|
27
35
|
}
|
|
28
36
|
for (const entry of entries) {
|
|
29
37
|
if (SKIP_DIRS.has(entry.name))
|
|
@@ -37,17 +45,212 @@ export async function* walkMarkdown(dir, base) {
|
|
|
37
45
|
}
|
|
38
46
|
}
|
|
39
47
|
}
|
|
48
|
+
function explicitMarkdownFiles(vault, collectionRelative, files) {
|
|
49
|
+
const selected = new Set();
|
|
50
|
+
for (const value of files) {
|
|
51
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
52
|
+
throw new Error("Embedding sync files must contain non-empty vault-relative paths.");
|
|
53
|
+
}
|
|
54
|
+
const normalized = value.replace(/\\/g, "/");
|
|
55
|
+
if (path.isAbsolute(normalized)) {
|
|
56
|
+
throw new Error("Embedding sync files must stay inside the vault.");
|
|
57
|
+
}
|
|
58
|
+
const fullPath = path.resolve(vault, normalized);
|
|
59
|
+
const relPath = path.relative(vault, fullPath).replace(/\\/g, "/");
|
|
60
|
+
if (relPath === ".." || relPath.startsWith("../") || !relPath.toLowerCase().endsWith(".md")) {
|
|
61
|
+
throw new Error(`Embedding sync file must be a markdown path inside the vault: ${value}`);
|
|
62
|
+
}
|
|
63
|
+
if (relPath.split("/").some((segment) => SKIP_DIRS.has(segment))) {
|
|
64
|
+
throw new Error(`Embedding sync file is inside an ignored vault directory: ${value}`);
|
|
65
|
+
}
|
|
66
|
+
if (!isDocumentInCollection(relPath, collectionRelative))
|
|
67
|
+
continue;
|
|
68
|
+
selected.add(relPath);
|
|
69
|
+
}
|
|
70
|
+
return [...selected].sort((left, right) => left.localeCompare(right));
|
|
71
|
+
}
|
|
72
|
+
async function* selectedMarkdownFiles(vault, collectionRoot, collectionRelative, files) {
|
|
73
|
+
if (files === undefined) {
|
|
74
|
+
yield* walkMarkdown(collectionRoot, vault);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
for (const relPath of explicitMarkdownFiles(vault, collectionRelative, files)) {
|
|
78
|
+
yield relPath;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
40
81
|
// ---------------------------------------------------------------------------
|
|
41
82
|
// Identity helpers
|
|
42
83
|
// ---------------------------------------------------------------------------
|
|
43
|
-
function
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
84
|
+
function storeHasAnyChunks(store) {
|
|
85
|
+
return store.listDocPaths().length > 0;
|
|
86
|
+
}
|
|
87
|
+
function identityEquivalent(a, b) {
|
|
88
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
89
|
+
for (const key of keys) {
|
|
90
|
+
if (a[key] !== b[key]) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
48
93
|
}
|
|
49
|
-
|
|
50
|
-
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
function providerIdentityMetadata(provider) {
|
|
97
|
+
return provider;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Acquire the writer lock without a dependency on a native locking package.
|
|
101
|
+
*
|
|
102
|
+
* A complete owner payload is published through a temporary O_EXCL inode and
|
|
103
|
+
* atomically claimed with a hard link. The file records the owner PID so a
|
|
104
|
+
* process killed between claim and release does not strand every future sync.
|
|
105
|
+
* A live owner is always rejected (rather than silently waiting), which keeps
|
|
106
|
+
* concurrent writers loud and bounded.
|
|
107
|
+
*/
|
|
108
|
+
export function acquireEngineStoreWriterLock(dbPath) {
|
|
109
|
+
const lockPath = `${dbPath}.lock`;
|
|
110
|
+
const lockDir = path.dirname(lockPath);
|
|
111
|
+
mkdirSync(lockDir, { recursive: true });
|
|
112
|
+
for (;;) {
|
|
113
|
+
// Publish the owner payload through a completed temporary inode, then
|
|
114
|
+
// claim the visible path with a hard link. Unlike open(..., "wx") followed
|
|
115
|
+
// by write(), this never exposes an empty/partial lock file for stale
|
|
116
|
+
// cleanup to mistake for a dead owner.
|
|
117
|
+
const tempPath = path.join(lockDir, `.${path.basename(lockPath)}.${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`);
|
|
118
|
+
let tempFd;
|
|
119
|
+
const ownerText = `${process.pid}\n${Date.now()}-${Math.random().toString(16).slice(2)}\n`;
|
|
120
|
+
try {
|
|
121
|
+
tempFd = openSync(tempPath, "wx");
|
|
122
|
+
const owner = Buffer.from(ownerText, "utf8");
|
|
123
|
+
writeSync(tempFd, owner, 0, owner.length, 0);
|
|
124
|
+
fsyncSync(tempFd);
|
|
125
|
+
closeSync(tempFd);
|
|
126
|
+
tempFd = undefined;
|
|
127
|
+
linkSync(tempPath, lockPath);
|
|
128
|
+
unlinkSync(tempPath);
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
const code = err.code;
|
|
132
|
+
if (tempFd !== undefined) {
|
|
133
|
+
try {
|
|
134
|
+
closeSync(tempFd);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// Preserve the original claim/write error.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
unlinkSync(tempPath);
|
|
142
|
+
}
|
|
143
|
+
catch (cleanupErr) {
|
|
144
|
+
if (cleanupErr.code !== "ENOENT")
|
|
145
|
+
throw cleanupErr;
|
|
146
|
+
}
|
|
147
|
+
if (code !== "EEXIST")
|
|
148
|
+
throw err;
|
|
149
|
+
let observedLockText;
|
|
150
|
+
let ownerPid;
|
|
151
|
+
try {
|
|
152
|
+
observedLockText = readFileSync(lockPath, "utf8");
|
|
153
|
+
const ownerText = observedLockText.trim().split(/\r?\n/, 1)[0] ?? "";
|
|
154
|
+
if (/^\d+$/.test(ownerText))
|
|
155
|
+
ownerPid = Number.parseInt(ownerText, 10);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// An unreadable lock is treated as stale.
|
|
159
|
+
}
|
|
160
|
+
if (ownerPid !== undefined && Number.isInteger(ownerPid) && ownerPid > 0) {
|
|
161
|
+
try {
|
|
162
|
+
process.kill(ownerPid, 0);
|
|
163
|
+
throw new Error(`Embedding sync is already in progress (lock: ${lockPath}).`);
|
|
164
|
+
}
|
|
165
|
+
catch (probeErr) {
|
|
166
|
+
if (probeErr.code !== "ESRCH")
|
|
167
|
+
throw probeErr;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Remove exactly the stale inode that was inspected. Renaming it away
|
|
171
|
+
// first prevents a second stale cleaner from unlinking a newly claimed
|
|
172
|
+
// lock between its read and delete operations.
|
|
173
|
+
const stalePath = path.join(lockDir, `.${path.basename(lockPath)}.stale-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
|
174
|
+
try {
|
|
175
|
+
renameSync(lockPath, stalePath);
|
|
176
|
+
}
|
|
177
|
+
catch (renameErr) {
|
|
178
|
+
if (renameErr.code === "ENOENT")
|
|
179
|
+
continue;
|
|
180
|
+
throw renameErr;
|
|
181
|
+
}
|
|
182
|
+
// Another stale cleaner may have replaced the path between our read and
|
|
183
|
+
// rename. Compare the complete owner payload (which includes a nonce for
|
|
184
|
+
// locks created here) before deleting the moved inode.
|
|
185
|
+
let movedLockText;
|
|
186
|
+
try {
|
|
187
|
+
movedLockText = readFileSync(stalePath, "utf8");
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Treat an unreadable moved inode as a failed identity check.
|
|
191
|
+
}
|
|
192
|
+
if (observedLockText === undefined || movedLockText !== observedLockText) {
|
|
193
|
+
let restored = false;
|
|
194
|
+
try {
|
|
195
|
+
linkSync(stalePath, lockPath);
|
|
196
|
+
restored = true;
|
|
197
|
+
}
|
|
198
|
+
catch (restoreErr) {
|
|
199
|
+
const restoreCode = restoreErr.code;
|
|
200
|
+
if (restoreCode !== "EEXIST" && restoreCode !== "ENOENT")
|
|
201
|
+
throw restoreErr;
|
|
202
|
+
}
|
|
203
|
+
if (restored) {
|
|
204
|
+
try {
|
|
205
|
+
unlinkSync(stalePath);
|
|
206
|
+
}
|
|
207
|
+
catch (unlinkErr) {
|
|
208
|
+
if (unlinkErr.code !== "ENOENT")
|
|
209
|
+
throw unlinkErr;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
unlinkSync(stalePath);
|
|
216
|
+
}
|
|
217
|
+
catch (unlinkErr) {
|
|
218
|
+
if (unlinkErr.code !== "ENOENT") {
|
|
219
|
+
// Keep the stale inode visible when cleanup fails, unless another
|
|
220
|
+
// writer already claimed the path after the atomic rename.
|
|
221
|
+
try {
|
|
222
|
+
linkSync(stalePath, lockPath);
|
|
223
|
+
}
|
|
224
|
+
catch (restoreErr) {
|
|
225
|
+
const restoreCode = restoreErr.code;
|
|
226
|
+
if (restoreCode !== "EEXIST" && restoreCode !== "ENOENT")
|
|
227
|
+
throw restoreErr;
|
|
228
|
+
}
|
|
229
|
+
throw unlinkErr;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
let released = false;
|
|
235
|
+
return () => {
|
|
236
|
+
if (released)
|
|
237
|
+
return;
|
|
238
|
+
released = true;
|
|
239
|
+
try {
|
|
240
|
+
if (readFileSync(lockPath, "utf8") !== ownerText)
|
|
241
|
+
return;
|
|
242
|
+
unlinkSync(lockPath);
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
if (err.code !== "ENOENT")
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function crashAt(point, expected) {
|
|
252
|
+
if (point === expected) {
|
|
253
|
+
throw new Error(`Injected generation swap crash at ${expected}.`);
|
|
51
254
|
}
|
|
52
255
|
}
|
|
53
256
|
function formatMismatchReason(args) {
|
|
@@ -62,6 +265,11 @@ function formatMismatchReason(args) {
|
|
|
62
265
|
return (`Embedding identity mismatch (${storedText}; ${cfgText}). ` +
|
|
63
266
|
`Rebuild vectors with ${args.forceFlagName}.`);
|
|
64
267
|
}
|
|
268
|
+
function isDocumentInCollection(docPath, collectionRoot) {
|
|
269
|
+
return collectionRoot === "" ||
|
|
270
|
+
docPath === collectionRoot ||
|
|
271
|
+
docPath.startsWith(`${collectionRoot}/`);
|
|
272
|
+
}
|
|
65
273
|
async function syncDocument(opts) {
|
|
66
274
|
let content;
|
|
67
275
|
try {
|
|
@@ -134,6 +342,124 @@ async function syncDocument(opts) {
|
|
|
134
342
|
if (toUpsert.length > 0)
|
|
135
343
|
opts.store.upsert(toUpsert);
|
|
136
344
|
}
|
|
345
|
+
async function rebuildGenerationAtomically(opts) {
|
|
346
|
+
if (opts.dbPath === ":memory:") {
|
|
347
|
+
throw new Error("Atomic generation swap requires a file-backed engine store.");
|
|
348
|
+
}
|
|
349
|
+
const directory = path.dirname(opts.dbPath);
|
|
350
|
+
const base = path.basename(opts.dbPath);
|
|
351
|
+
let shadowPath;
|
|
352
|
+
let shadow = null;
|
|
353
|
+
let swapped = false;
|
|
354
|
+
let swapPreparationStarted = false;
|
|
355
|
+
try {
|
|
356
|
+
// Keep the shadow in the same directory so rename(2) is one filesystem
|
|
357
|
+
// operation. The random suffix also prevents a previous crash from
|
|
358
|
+
// colliding with a future generation.
|
|
359
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
360
|
+
const candidate = path.join(directory, `.${base}.generation-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.sqlite`);
|
|
361
|
+
if (!existsSync(candidate)) {
|
|
362
|
+
shadowPath = candidate;
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (!shadowPath)
|
|
367
|
+
throw new Error("Could not allocate a shadow embedding generation path.");
|
|
368
|
+
shadow = openEngineStore(shadowPath, opts.provider.dimensions);
|
|
369
|
+
if (!shadow.capabilities().vecAvailable) {
|
|
370
|
+
throw new Error("Vector layer unavailable while building the shadow generation.");
|
|
371
|
+
}
|
|
372
|
+
const expectedDocs = new Set();
|
|
373
|
+
for await (const relPath of selectedMarkdownFiles(opts.vault, opts.collectionRoot, opts.collectionRelative, opts.files)) {
|
|
374
|
+
expectedDocs.add(relPath);
|
|
375
|
+
await syncDocument({
|
|
376
|
+
relPath,
|
|
377
|
+
vault: opts.vault,
|
|
378
|
+
store: shadow,
|
|
379
|
+
provider: opts.provider,
|
|
380
|
+
shouldEmbed: true,
|
|
381
|
+
chunkerOpts: opts.chunkerOpts,
|
|
382
|
+
counters: opts.counters,
|
|
383
|
+
rebuildAllVectors: true,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
crashAt(opts.crashPoint, "after-build");
|
|
387
|
+
// Identity is written only to the inactive generation. The live
|
|
388
|
+
// generation remains byte-for-byte unchanged until the rename below.
|
|
389
|
+
shadow.writeEmbeddingIdentity(opts.configuredIdentity);
|
|
390
|
+
const writtenIdentity = shadow.readEmbeddingIdentity();
|
|
391
|
+
if (writtenIdentity === null || !identityEquivalent(writtenIdentity, opts.configuredIdentity)) {
|
|
392
|
+
throw new Error("Shadow embedding generation failed identity validation.");
|
|
393
|
+
}
|
|
394
|
+
const actualDocs = new Set(shadow.listDocPaths());
|
|
395
|
+
if (actualDocs.size !== expectedDocs.size || [...expectedDocs].some((doc) => !actualDocs.has(doc))) {
|
|
396
|
+
throw new Error("Shadow embedding generation failed document validation.");
|
|
397
|
+
}
|
|
398
|
+
crashAt(opts.crashPoint, "after-validation");
|
|
399
|
+
// Keep the active handle open while building and validating the shadow.
|
|
400
|
+
// Closing it only here ensures no active bytes or identity can change
|
|
401
|
+
// before validation has completed. Any long-lived reader owned by the
|
|
402
|
+
// caller must close at the same boundary so its WAL/SHM descriptors do
|
|
403
|
+
// not survive the rename.
|
|
404
|
+
swapPreparationStarted = true;
|
|
405
|
+
opts.onGenerationSwapPrepare?.();
|
|
406
|
+
opts.closeActive();
|
|
407
|
+
// close() checkpoints WAL and releases every shadow descriptor before
|
|
408
|
+
// rename. The active sidecars are removed only after every known active
|
|
409
|
+
// handle has closed; stale files from an interrupted run are harmless.
|
|
410
|
+
unlinkStaleSidecars(opts.dbPath);
|
|
411
|
+
shadow.close();
|
|
412
|
+
shadow = null;
|
|
413
|
+
crashAt(opts.crashPoint, "before-swap");
|
|
414
|
+
// rename is atomic on the same filesystem: readers observe either the
|
|
415
|
+
// complete old database or the complete new one, never a partial build.
|
|
416
|
+
// Future EAV/axis state deliberately lives in its own database and is not
|
|
417
|
+
// copied into this embedding generation.
|
|
418
|
+
unlinkStaleSidecars(shadowPath);
|
|
419
|
+
renameGeneration(shadowPath, opts.dbPath);
|
|
420
|
+
swapped = true;
|
|
421
|
+
opts.onSwap?.();
|
|
422
|
+
opts.onGenerationSwapComplete?.(true);
|
|
423
|
+
crashAt(opts.crashPoint, "after-swap");
|
|
424
|
+
}
|
|
425
|
+
finally {
|
|
426
|
+
try {
|
|
427
|
+
shadow?.close();
|
|
428
|
+
if (shadowPath && !swapped) {
|
|
429
|
+
unlinkStaleSidecars(shadowPath);
|
|
430
|
+
try {
|
|
431
|
+
unlinkSync(shadowPath);
|
|
432
|
+
}
|
|
433
|
+
catch (err) {
|
|
434
|
+
if (err.code !== "ENOENT")
|
|
435
|
+
throw err;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
finally {
|
|
440
|
+
// A crash or validation failure after preparation still closed the
|
|
441
|
+
// caller's long-lived handle. Give it a chance to rebind to the intact
|
|
442
|
+
// old generation before releasing the cross-process writer lock.
|
|
443
|
+
if (swapPreparationStarted && !swapped) {
|
|
444
|
+
opts.onGenerationSwapComplete?.(false);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function unlinkStaleSidecars(dbPath) {
|
|
450
|
+
for (const suffix of ["-wal", "-shm"]) {
|
|
451
|
+
try {
|
|
452
|
+
unlinkSync(`${dbPath}${suffix}`);
|
|
453
|
+
}
|
|
454
|
+
catch (err) {
|
|
455
|
+
if (err.code !== "ENOENT")
|
|
456
|
+
throw err;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
function renameGeneration(shadowPath, activePath) {
|
|
461
|
+
renameSync(shadowPath, activePath);
|
|
462
|
+
}
|
|
137
463
|
// ---------------------------------------------------------------------------
|
|
138
464
|
// Public entry point
|
|
139
465
|
// ---------------------------------------------------------------------------
|
|
@@ -141,22 +467,34 @@ export async function syncEngineStore(opts) {
|
|
|
141
467
|
const vault = path.resolve(opts.vault);
|
|
142
468
|
const collection = opts.collection ?? "vault";
|
|
143
469
|
const collectionRoot = opts.collectionPath ? path.resolve(vault, opts.collectionPath) : vault;
|
|
470
|
+
const collectionRelative = path.relative(vault, collectionRoot).replace(/\\/g, "/");
|
|
471
|
+
const invalidCollectionPath = path.isAbsolute(collectionRelative) ||
|
|
472
|
+
collectionRelative === ".." ||
|
|
473
|
+
collectionRelative.startsWith("../");
|
|
144
474
|
const dbPath = opts.dbPath ?? path.join(vault, ".oms", "engine-store.sqlite");
|
|
145
475
|
const shouldEmbed = opts.embed !== false;
|
|
146
476
|
const force = opts.force === true;
|
|
477
|
+
const persist = opts.persist !== false;
|
|
147
478
|
const warnings = [];
|
|
148
479
|
let provider = null;
|
|
149
480
|
let store = opts.store ?? null;
|
|
150
481
|
const ownsStore = opts.store === undefined;
|
|
151
482
|
let storedIdentity;
|
|
152
483
|
let configuredIdentity;
|
|
484
|
+
let generationSwapped = false;
|
|
485
|
+
let releaseLock = null;
|
|
153
486
|
try {
|
|
487
|
+
if (invalidCollectionPath) {
|
|
488
|
+
throw new Error("Embedding sync collectionPath must stay inside the vault.");
|
|
489
|
+
}
|
|
154
490
|
if (!shouldEmbed) {
|
|
155
491
|
// Lex-only path: no embedding provider required.
|
|
492
|
+
if (persist)
|
|
493
|
+
releaseLock = acquireEngineStoreWriterLock(dbPath);
|
|
156
494
|
store ??= openEngineStoreCore(dbPath);
|
|
157
495
|
warnings.push("embed=false: lexical index updated; no vectors generated");
|
|
158
496
|
const counters = { scanned: 0, added: 0, updated: 0, skipped: 0 };
|
|
159
|
-
for await (const relPath of
|
|
497
|
+
for await (const relPath of selectedMarkdownFiles(vault, collectionRoot, collectionRelative, opts.files)) {
|
|
160
498
|
await syncDocument({
|
|
161
499
|
relPath,
|
|
162
500
|
vault,
|
|
@@ -179,16 +517,73 @@ export async function syncEngineStore(opts) {
|
|
|
179
517
|
skipped: counters.skipped,
|
|
180
518
|
};
|
|
181
519
|
}
|
|
182
|
-
const
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
520
|
+
const descriptor = opts.embeddingDescriptor;
|
|
521
|
+
const embeddingProvider = (opts.embeddingProvider ??
|
|
522
|
+
descriptor?.provider ??
|
|
523
|
+
"").trim();
|
|
524
|
+
const embeddingModel = (opts.embeddingModel ??
|
|
525
|
+
(descriptor?.path || descriptor?.modelPath) ??
|
|
526
|
+
descriptor?.model ??
|
|
527
|
+
"").trim();
|
|
528
|
+
const embeddingDimensions = opts.embeddingDimensions ?? descriptor?.dimensions;
|
|
529
|
+
const embeddingContext = opts.embeddingContext ?? descriptor?.context;
|
|
530
|
+
const embeddingContextLength = opts.embeddingContextLength ?? descriptor?.contextLength;
|
|
531
|
+
const embeddingContextTokens = opts.embeddingContextTokens ?? descriptor?.contextTokens;
|
|
532
|
+
const embeddingMrlDim = opts.embeddingMrlDim ?? descriptor?.mrlDim;
|
|
533
|
+
const embeddingNormalization = opts.embeddingNormalization ?? descriptor?.normalization;
|
|
534
|
+
const embeddingPrefixScheme = opts.embeddingPrefixScheme ?? descriptor?.prefixScheme;
|
|
535
|
+
if (descriptor !== undefined) {
|
|
536
|
+
const descriptorContext = embeddingContext ?? embeddingContextLength ?? embeddingContextTokens;
|
|
537
|
+
if (typeof embeddingDimensions !== "number" ||
|
|
538
|
+
typeof descriptorContext !== "number" ||
|
|
539
|
+
typeof embeddingMrlDim !== "number" ||
|
|
540
|
+
typeof embeddingNormalization !== "string" ||
|
|
541
|
+
!embeddingNormalization.trim() ||
|
|
542
|
+
typeof embeddingPrefixScheme !== "string" ||
|
|
543
|
+
!embeddingPrefixScheme.trim()) {
|
|
544
|
+
throw new Error("Embedding descriptor is incomplete. dimensions/context/mrlDim/normalization/prefixScheme are required.");
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
provider = requireRealEmbeddingProvider({
|
|
548
|
+
provider: embeddingProvider,
|
|
549
|
+
model: embeddingModel,
|
|
550
|
+
dimensions: embeddingDimensions,
|
|
551
|
+
context: embeddingContext,
|
|
552
|
+
contextLength: embeddingContextLength,
|
|
553
|
+
contextTokens: embeddingContextTokens,
|
|
554
|
+
mrlDim: embeddingMrlDim,
|
|
555
|
+
normalization: embeddingNormalization,
|
|
556
|
+
prefixScheme: embeddingPrefixScheme,
|
|
557
|
+
});
|
|
558
|
+
const metadata = providerIdentityMetadata(provider);
|
|
559
|
+
const contextLength = embeddingContext ??
|
|
560
|
+
embeddingContextLength ??
|
|
561
|
+
embeddingContextTokens ??
|
|
562
|
+
metadata.context ??
|
|
563
|
+
metadata.contextLength;
|
|
564
|
+
const mrlDim = embeddingMrlDim ?? metadata.mrlDim;
|
|
565
|
+
const normalization = embeddingNormalization ?? metadata.normalization;
|
|
566
|
+
const prefixScheme = embeddingPrefixScheme ?? metadata.prefixScheme;
|
|
567
|
+
if (contextLength === undefined || mrlDim === undefined || normalization === undefined || prefixScheme === undefined) {
|
|
568
|
+
throw new Error("Embedding descriptor is incomplete. dimensions/context/mrlDim/normalization/prefixScheme are required for vector sync.");
|
|
569
|
+
}
|
|
570
|
+
const identity = makeEmbeddingIdentity({
|
|
186
571
|
provider: embeddingProvider,
|
|
187
572
|
model: embeddingModel,
|
|
188
573
|
dimensions: provider.dimensions,
|
|
574
|
+
contextLength,
|
|
575
|
+
mrlDim,
|
|
576
|
+
normalization,
|
|
577
|
+
prefixScheme,
|
|
189
578
|
});
|
|
190
|
-
|
|
191
|
-
|
|
579
|
+
configuredIdentity = identity;
|
|
580
|
+
// Lock before opening the write handle so identity inspection and every
|
|
581
|
+
// subsequent write observe one writer generation.
|
|
582
|
+
if (persist)
|
|
583
|
+
releaseLock = acquireEngineStoreWriterLock(dbPath);
|
|
584
|
+
// A caller-owned handle is used as-is and is never closed by this
|
|
585
|
+
// function. Otherwise sync owns the handle it opens below.
|
|
586
|
+
store ??= openEngineStore(dbPath, provider.dimensions);
|
|
192
587
|
if (!store.capabilities().vecAvailable) {
|
|
193
588
|
warnings.push("Vector layer unavailable: sqlite-vec not loaded");
|
|
194
589
|
return {
|
|
@@ -204,15 +599,25 @@ export async function syncEngineStore(opts) {
|
|
|
204
599
|
configuredIdentity,
|
|
205
600
|
};
|
|
206
601
|
}
|
|
207
|
-
|
|
602
|
+
try {
|
|
603
|
+
storedIdentity = store.readEmbeddingIdentity() ?? undefined;
|
|
604
|
+
}
|
|
605
|
+
catch (error) {
|
|
606
|
+
// A stale metadata version is intentionally not decoded as an identity.
|
|
607
|
+
// Force mode can still rebuild it through the atomic generation path;
|
|
608
|
+
// without force, preserve the loud rejection reason.
|
|
609
|
+
if (force && error instanceof Error && /metadata version/i.test(error.message)) {
|
|
610
|
+
storedIdentity = undefined;
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
throw error;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
208
616
|
// If there are indexed chunks on disk but no stored identity, treat as mismatch.
|
|
209
|
-
const hasStoredChunks =
|
|
617
|
+
const hasStoredChunks = storeHasAnyChunks(store);
|
|
210
618
|
const missingStoredIdentity = storedIdentity === undefined;
|
|
211
619
|
const mismatch = (storedIdentity !== undefined &&
|
|
212
|
-
(storedIdentity
|
|
213
|
-
storedIdentity.model !== configuredIdentity.model ||
|
|
214
|
-
storedIdentity.dimensions !== configuredIdentity.dimensions ||
|
|
215
|
-
storedIdentity.fingerprint !== configuredIdentity.fingerprint)) ||
|
|
620
|
+
!identityEquivalent(storedIdentity, identity)) ||
|
|
216
621
|
(missingStoredIdentity && hasStoredChunks);
|
|
217
622
|
if (mismatch && !force) {
|
|
218
623
|
return {
|
|
@@ -233,45 +638,66 @@ export async function syncEngineStore(opts) {
|
|
|
233
638
|
configuredIdentity,
|
|
234
639
|
};
|
|
235
640
|
}
|
|
236
|
-
// On force mismatch: destructive rebuild of the vec0 table.
|
|
237
|
-
let rebuildAllVectors = false;
|
|
238
641
|
if (mismatch && force) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
store = null;
|
|
242
|
-
const db = new Database(dbPath);
|
|
243
|
-
try {
|
|
244
|
-
DEFAULT_SQLITE_VEC_LOADER(db);
|
|
245
|
-
db.exec("DROP TABLE IF EXISTS engine_chunk_vec;");
|
|
246
|
-
db.exec(`CREATE VIRTUAL TABLE engine_chunk_vec USING vec0(embedding float[${provider.dimensions}]);`);
|
|
247
|
-
rebuildAllVectors = true;
|
|
642
|
+
if (!ownsStore) {
|
|
643
|
+
throw new Error("Force reindex requires an internally-owned store; close the caller-owned handle and retry without store.");
|
|
248
644
|
}
|
|
249
|
-
|
|
250
|
-
|
|
645
|
+
if (collectionRelative !== "") {
|
|
646
|
+
const outOfScope = store.listDocPaths().filter((docPath) => !isDocumentInCollection(docPath, collectionRelative));
|
|
647
|
+
if (outOfScope.length > 0) {
|
|
648
|
+
throw new Error(`Scoped force reindex refused: ${outOfScope.length} indexed document(s) fall outside "${collectionRelative}". ` +
|
|
649
|
+
"Run an unscoped force reindex to replace the complete generation.");
|
|
650
|
+
}
|
|
251
651
|
}
|
|
252
|
-
//
|
|
652
|
+
// Build a complete shadow generation before touching the active path.
|
|
653
|
+
// Caller-owned handles are rejected above rather than being closed or
|
|
654
|
+
// left pointing at a removed table.
|
|
655
|
+
const counters = { scanned: 0, added: 0, updated: 0, skipped: 0 };
|
|
656
|
+
await rebuildGenerationAtomically({
|
|
657
|
+
dbPath,
|
|
658
|
+
vault,
|
|
659
|
+
collectionRoot,
|
|
660
|
+
collectionRelative,
|
|
661
|
+
files: opts.files,
|
|
662
|
+
provider,
|
|
663
|
+
configuredIdentity: identity,
|
|
664
|
+
chunkerOpts: opts.chunkerOpts,
|
|
665
|
+
counters,
|
|
666
|
+
crashPoint: opts.crashPoint,
|
|
667
|
+
onGenerationSwapPrepare: opts.onGenerationSwapPrepare,
|
|
668
|
+
onGenerationSwapComplete: opts.onGenerationSwapComplete,
|
|
669
|
+
onSwap: () => {
|
|
670
|
+
generationSwapped = true;
|
|
671
|
+
},
|
|
672
|
+
closeActive: () => {
|
|
673
|
+
if (!store)
|
|
674
|
+
throw new Error("Internal error: active store is already closed.");
|
|
675
|
+
store.close();
|
|
676
|
+
store = null;
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
// The active handle was intentionally closed before the atomic rename.
|
|
680
|
+
// Reopen it so callers that inspect the owned handle after the swap do
|
|
681
|
+
// not retain prepared statements bound to the retired inode.
|
|
253
682
|
store = openEngineStore(dbPath, provider.dimensions);
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
};
|
|
268
|
-
}
|
|
683
|
+
return {
|
|
684
|
+
available: true,
|
|
685
|
+
warnings,
|
|
686
|
+
collection,
|
|
687
|
+
dbPath,
|
|
688
|
+
scanned: counters.scanned,
|
|
689
|
+
added: counters.added,
|
|
690
|
+
updated: counters.updated,
|
|
691
|
+
skipped: counters.skipped,
|
|
692
|
+
storedIdentity,
|
|
693
|
+
configuredIdentity,
|
|
694
|
+
generationSwapped,
|
|
695
|
+
};
|
|
269
696
|
}
|
|
270
|
-
if (!store)
|
|
697
|
+
if (!store)
|
|
271
698
|
throw new Error("Internal error: engine store is null after preparation.");
|
|
272
|
-
}
|
|
273
699
|
const counters = { scanned: 0, added: 0, updated: 0, skipped: 0 };
|
|
274
|
-
for await (const relPath of
|
|
700
|
+
for await (const relPath of selectedMarkdownFiles(vault, collectionRoot, collectionRelative, opts.files)) {
|
|
275
701
|
await syncDocument({
|
|
276
702
|
relPath,
|
|
277
703
|
vault,
|
|
@@ -280,11 +706,12 @@ export async function syncEngineStore(opts) {
|
|
|
280
706
|
shouldEmbed: true,
|
|
281
707
|
chunkerOpts: opts.chunkerOpts,
|
|
282
708
|
counters,
|
|
283
|
-
rebuildAllVectors,
|
|
709
|
+
rebuildAllVectors: false,
|
|
284
710
|
});
|
|
285
711
|
}
|
|
286
|
-
// Persist configured embedding identity only after a successful embed=true
|
|
287
|
-
|
|
712
|
+
// Persist configured embedding identity only after a successful embed=true
|
|
713
|
+
// incremental sync.
|
|
714
|
+
store.writeEmbeddingIdentity(identity);
|
|
288
715
|
return {
|
|
289
716
|
available: true,
|
|
290
717
|
warnings,
|
|
@@ -296,6 +723,7 @@ export async function syncEngineStore(opts) {
|
|
|
296
723
|
skipped: counters.skipped,
|
|
297
724
|
storedIdentity,
|
|
298
725
|
configuredIdentity,
|
|
726
|
+
generationSwapped,
|
|
299
727
|
};
|
|
300
728
|
}
|
|
301
729
|
catch (err) {
|
|
@@ -312,12 +740,14 @@ export async function syncEngineStore(opts) {
|
|
|
312
740
|
skipped: 0,
|
|
313
741
|
storedIdentity,
|
|
314
742
|
configuredIdentity,
|
|
743
|
+
generationSwapped,
|
|
315
744
|
};
|
|
316
745
|
}
|
|
317
746
|
finally {
|
|
318
747
|
await provider?.dispose().catch(() => undefined);
|
|
319
748
|
if (ownsStore)
|
|
320
749
|
store?.close();
|
|
750
|
+
releaseLock?.();
|
|
321
751
|
}
|
|
322
752
|
}
|
|
323
753
|
//# sourceMappingURL=sync.js.map
|