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.
Files changed (110) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/ACKNOWLEDGMENTS.md +8 -0
  5. package/CHANGELOG-assets.md +10 -0
  6. package/CHANGELOG-cli.md +11 -0
  7. package/CHANGELOG-kernel.md +31 -0
  8. package/CHANGELOG-mcp.md +12 -0
  9. package/CHANGELOG-vendors.md +6 -0
  10. package/CHANGELOG.md +35 -0
  11. package/assets/hermes-manifest.json +1 -1
  12. package/assets/skills/doctor/SKILL.md +2 -2
  13. package/assets/skills/search/SKILL.md +17 -1
  14. package/dist/cli/args.d.ts +2 -0
  15. package/dist/cli/args.js +13 -0
  16. package/dist/cli/args.js.map +1 -1
  17. package/dist/cli/host-commands.js +2 -1
  18. package/dist/cli/host-commands.js.map +1 -1
  19. package/dist/cli/oms.d.ts +1 -1
  20. package/dist/cli/oms.js +26 -3
  21. package/dist/cli/oms.js.map +1 -1
  22. package/dist/cli/semantic-args.js +102 -3
  23. package/dist/cli/semantic-args.js.map +1 -1
  24. package/dist/cli/semantic.js +30 -6
  25. package/dist/cli/semantic.js.map +1 -1
  26. package/dist/cli/setup-command.d.ts +16 -0
  27. package/dist/cli/setup-command.js +50 -1
  28. package/dist/cli/setup-command.js.map +1 -1
  29. package/dist/kernel/contracts/index.d.ts +144 -0
  30. package/dist/kernel/contracts/index.js +401 -0
  31. package/dist/kernel/contracts/index.js.map +1 -0
  32. package/dist/kernel/conventions/note-exclude.d.ts +14 -0
  33. package/dist/kernel/conventions/note-exclude.js +95 -0
  34. package/dist/kernel/conventions/note-exclude.js.map +1 -0
  35. package/dist/kernel/engine/assemble.d.ts +22 -1
  36. package/dist/kernel/engine/assemble.js +202 -19
  37. package/dist/kernel/engine/assemble.js.map +1 -1
  38. package/dist/kernel/engine/axes/store.d.ts +90 -0
  39. package/dist/kernel/engine/axes/store.js +422 -0
  40. package/dist/kernel/engine/axes/store.js.map +1 -0
  41. package/dist/kernel/engine/conventions/vault-lint.d.ts +7 -2
  42. package/dist/kernel/engine/conventions/vault-lint.js +7 -21
  43. package/dist/kernel/engine/conventions/vault-lint.js.map +1 -1
  44. package/dist/kernel/engine/embed/identity.d.ts +14 -4
  45. package/dist/kernel/engine/embed/identity.js +33 -1
  46. package/dist/kernel/engine/embed/identity.js.map +1 -1
  47. package/dist/kernel/engine/embed/model.d.ts +134 -0
  48. package/dist/kernel/engine/embed/model.js +302 -0
  49. package/dist/kernel/engine/embed/model.js.map +1 -0
  50. package/dist/kernel/engine/embed/provider.d.ts +36 -5
  51. package/dist/kernel/engine/embed/provider.js +240 -103
  52. package/dist/kernel/engine/embed/provider.js.map +1 -1
  53. package/dist/kernel/engine/embed/store.d.ts +5 -1
  54. package/dist/kernel/engine/embed/store.js +136 -26
  55. package/dist/kernel/engine/embed/store.js.map +1 -1
  56. package/dist/kernel/engine/embed/sync.d.ts +53 -0
  57. package/dist/kernel/engine/embed/sync.js +489 -59
  58. package/dist/kernel/engine/embed/sync.js.map +1 -1
  59. package/dist/kernel/engine/graph/builder.d.ts +9 -5
  60. package/dist/kernel/engine/graph/builder.js +225 -82
  61. package/dist/kernel/engine/graph/builder.js.map +1 -1
  62. package/dist/kernel/engine/graph/node.d.ts +49 -1
  63. package/dist/kernel/engine/graph/node.js +205 -0
  64. package/dist/kernel/engine/graph/node.js.map +1 -1
  65. package/dist/kernel/engine/mcp/facade.d.ts +21 -1
  66. package/dist/kernel/engine/mcp/facade.js +523 -28
  67. package/dist/kernel/engine/mcp/facade.js.map +1 -1
  68. package/dist/kernel/engine/mcp/query-mapper.d.ts +27 -2
  69. package/dist/kernel/engine/mcp/query-mapper.js +157 -12
  70. package/dist/kernel/engine/mcp/query-mapper.js.map +1 -1
  71. package/dist/kernel/engine/mcp/types.d.ts +64 -2
  72. package/dist/kernel/engine/retrieval/dispatcher.d.ts +22 -0
  73. package/dist/kernel/engine/retrieval/dispatcher.js +84 -8
  74. package/dist/kernel/engine/retrieval/dispatcher.js.map +1 -1
  75. package/dist/kernel/engine/retrieval/index.d.ts +2 -1
  76. package/dist/kernel/engine/retrieval/index.js +1 -1
  77. package/dist/kernel/engine/retrieval/index.js.map +1 -1
  78. package/dist/kernel/engine/retrieval/reranker.d.ts +82 -4
  79. package/dist/kernel/engine/retrieval/reranker.js +178 -3
  80. package/dist/kernel/engine/retrieval/reranker.js.map +1 -1
  81. package/dist/kernel/engine/retrieval/rrf.d.ts +10 -2
  82. package/dist/kernel/engine/retrieval/rrf.js +39 -16
  83. package/dist/kernel/engine/retrieval/rrf.js.map +1 -1
  84. package/dist/kernel/engine/tracer.d.ts +13 -0
  85. package/dist/kernel/engine/tracer.js +63 -6
  86. package/dist/kernel/engine/tracer.js.map +1 -1
  87. package/dist/kernel/graph/cache.js +174 -23
  88. package/dist/kernel/graph/cache.js.map +1 -1
  89. package/dist/kernel/index.d.ts +2 -0
  90. package/dist/kernel/index.js +2 -0
  91. package/dist/kernel/index.js.map +1 -1
  92. package/dist/kernel/measurement/no-default-contract.d.ts +17 -0
  93. package/dist/kernel/measurement/no-default-contract.js +84 -0
  94. package/dist/kernel/measurement/no-default-contract.js.map +1 -0
  95. package/dist/kernel/searchbackend/engine-search-backend.js +93 -7
  96. package/dist/kernel/searchbackend/engine-search-backend.js.map +1 -1
  97. package/dist/kernel/searchbackend/search-backend.d.ts +11 -1
  98. package/dist/kernel/searchbackend/search-backend.js +66 -5
  99. package/dist/kernel/searchbackend/search-backend.js.map +1 -1
  100. package/dist/kernel/semantic/semantic-engine.js +31 -5
  101. package/dist/kernel/semantic/semantic-engine.js.map +1 -1
  102. package/dist/kernel/semantic/semantic-retrieve-args.d.ts +19 -2
  103. package/dist/kernel/semantic/semantic-retrieve-args.js +81 -7
  104. package/dist/kernel/semantic/semantic-retrieve-args.js.map +1 -1
  105. package/dist/kernel/update/update.d.ts +7 -0
  106. package/dist/kernel/update/update.js +106 -8
  107. package/dist/kernel/update/update.js.map +1 -1
  108. package/dist/mcp/server.js +84 -34
  109. package/dist/mcp/server.js.map +1 -1
  110. 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 { DEFAULT_SQLITE_VEC_LOADER, openEngineStore, openEngineStoreCore } from "./store.js";
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
- return;
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 dbHasAnyChunks(dbPath) {
44
- const db = new Database(dbPath, { readonly: true });
45
- try {
46
- const row = db.prepare("SELECT rowid FROM engine_chunk_meta LIMIT 1").get();
47
- return row !== undefined;
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
- finally {
50
- db.close();
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 walkMarkdown(collectionRoot, vault)) {
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 embeddingProvider = (opts.embeddingProvider ?? "").trim();
183
- const embeddingModel = (opts.embeddingModel ?? "").trim();
184
- provider = requireRealEmbeddingProvider({ provider: embeddingProvider, model: embeddingModel });
185
- configuredIdentity = makeEmbeddingIdentity({
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
- // Vector-capable open.
191
- store = openEngineStore(dbPath, provider.dimensions);
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
- storedIdentity = store.readEmbeddingIdentity() ?? undefined;
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 = dbHasAnyChunks(dbPath);
617
+ const hasStoredChunks = storeHasAnyChunks(store);
210
618
  const missingStoredIdentity = storedIdentity === undefined;
211
619
  const mismatch = (storedIdentity !== undefined &&
212
- (storedIdentity.provider !== configuredIdentity.provider ||
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
- // Close the live store handle before mutating the vec0 table.
240
- store.close();
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
- finally {
250
- db.close();
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
- // Re-open so prepared statements reflect the rebuilt vec table.
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
- if (!store.capabilities().vecAvailable) {
255
- warnings.push("Vector layer unavailable: sqlite-vec not loaded");
256
- return {
257
- available: false,
258
- reason: "Vector layer unavailable: sqlite-vec not loaded.",
259
- warnings,
260
- collection,
261
- dbPath,
262
- scanned: 0,
263
- added: 0,
264
- updated: 0,
265
- skipped: 0,
266
- configuredIdentity,
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 walkMarkdown(collectionRoot, vault)) {
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 sync.
287
- store.writeEmbeddingIdentity(configuredIdentity);
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