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
@@ -20,19 +20,18 @@
20
20
  * R18: NO import from src/search.
21
21
  */
22
22
  import path from "node:path";
23
- import { readFileSync } from "node:fs";
23
+ import { existsSync, readFileSync } from "node:fs";
24
+ import { parse as parseYaml } from "yaml";
25
+ import { resolveBundledAssetPaths } from "../../runtime/assets.js";
26
+ import { loadContract } from "../../contracts/index.js";
24
27
  import { retrieve } from "../retrieval/index.js";
25
- import { syncEngineStore, walkMarkdown } from "../embed/sync.js";
26
- import { buildGraph, saveCachedGraph, loadCachedGraphMeta, buildNodeIndex, saveNodeIndex, loadNodeIndex, } from "../graph/builder.js";
27
- import { filterNodesByAxis, searchScore } from "../graph/node.js";
28
- import { queryOptionsToSubQueries, retrievalResultsToQueryResult, queryResultUnavailable, } from "./query-mapper.js";
28
+ import { acquireEngineStoreWriterLock, syncEngineStore, walkMarkdown, } from "../embed/sync.js";
29
+ import { openEngineStore } from "../embed/store.js";
30
+ import { buildGraph, saveCachedGraph, loadCachedGraphMeta, buildNodeIndex, saveNodeIndex, loadNodeIndex, nodeSourceSignature, } from "../graph/builder.js";
31
+ import { filterNodesByAxis, filterNodesByQueryAxes, queryFacets, searchScore, } from "../graph/node.js";
32
+ import { axisStorePath, collectVaultAxisObservations, openAxisStore, } from "../axes/store.js";
33
+ import { normalizeQueryOptions, retrievalResultsToQueryResult, queryResultUnavailable, } from "./query-mapper.js";
29
34
  import { engineSyncResultToMcp, syncResultUnavailable, capsToEngineStatusResult, engineStatusResultToMcp, statusResultUnavailable, engineStatusToCollectionResult, engineStatusToContextResult, cleanupResultUnavailable, graphBuildOptionsToEngineArgs, engineGraphBuildResultToMcp, engineGraphBuildToStatusResult, } from "./op-mappers.js";
30
- // ---------------------------------------------------------------------------
31
- // Helpers
32
- // ---------------------------------------------------------------------------
33
- function clamp(n, lo, hi) {
34
- return Math.min(Math.max(n, lo), hi);
35
- }
36
35
  function positiveInt(value) {
37
36
  if (!value || !/^\d+$/u.test(value))
38
37
  return undefined;
@@ -197,11 +196,298 @@ function enrichQueryHits(result, vault) {
197
196
  const title = extractDocTitle(raw);
198
197
  return { ...hit, snippet: docHeadSnippet(raw), ...(title !== undefined ? { title } : {}) };
199
198
  });
200
- return { available: true, hits };
199
+ return { ...result, available: true, hits };
200
+ }
201
+ /** Read folder intent declarations without creating or updating vault state. */
202
+ function folderIntents(vault) {
203
+ const candidates = [
204
+ path.join(vault, ".oms", "taxonomy.yaml"),
205
+ path.join(vault, "taxonomy.yaml"),
206
+ ];
207
+ try {
208
+ candidates.push(path.join(resolveBundledAssetPaths().ontologyDir, "taxonomy.yaml"));
209
+ }
210
+ catch {
211
+ // A package without bundled ontology still serves axis results; intents
212
+ // remain optional metadata.
213
+ }
214
+ for (const candidate of candidates) {
215
+ try {
216
+ const parsed = parseYaml(readFileSync(candidate, "utf-8"));
217
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
218
+ continue;
219
+ const folders = parsed["folders"];
220
+ if (!folders || typeof folders !== "object" || Array.isArray(folders))
221
+ continue;
222
+ const intents = new Map();
223
+ for (const [folder, value] of Object.entries(folders)) {
224
+ if (!value || typeof value !== "object" || Array.isArray(value))
225
+ continue;
226
+ const intent = value["intent"];
227
+ if (typeof intent === "string" && intent.trim())
228
+ intents.set(folder, intent.trim());
229
+ }
230
+ return intents;
231
+ }
232
+ catch {
233
+ // A missing optional taxonomy is not an error; a malformed one simply
234
+ // contributes no intent and never causes a write.
235
+ }
236
+ }
237
+ return new Map();
238
+ }
239
+ function facetIntent(facet, intents, queryIntent) {
240
+ if (facet.axis === "folder")
241
+ return intents.get(facet.value) ?? `Folder axis: ${facet.value}`;
242
+ if (queryIntent !== undefined && queryIntent.trim().length > 0)
243
+ return queryIntent.trim();
244
+ if (facet.axis === "field")
245
+ return `Frontmatter field axis: ${facet.key ?? "unknown"}`;
246
+ return `Link axis: ${facet.value}`;
201
247
  }
202
248
  function isLexOnlySubQueries(subQueries) {
203
249
  return subQueries.length > 0 && subQueries.every((subQuery) => subQuery.type === "lex");
204
250
  }
251
+ /** Return true when the caller explicitly requested an embedding channel. */
252
+ function hasExplicitEmbeddingSearch(opts) {
253
+ if (opts.mode === "vsearch")
254
+ return true;
255
+ if (typeof opts.vec === "string" && opts.vec.trim().length > 0)
256
+ return true;
257
+ if (typeof opts.hyde === "string" && opts.hyde.trim().length > 0)
258
+ return true;
259
+ return (opts.searches ?? []).some((search) => search.type === "vec" || search.type === "hyde");
260
+ }
261
+ const UNBOUNDED_CANDIDATE_LIMIT = Number.MAX_SAFE_INTEGER;
262
+ /**
263
+ * Collection aggregation asks each child query for every candidate and applies
264
+ * one global page after merging. A direct query without a limit still keeps
265
+ * the public ten-hit default.
266
+ */
267
+ function resultPageLimit(opts) {
268
+ return opts.limit ?? (opts.collectionPath === undefined ? undefined : UNBOUNDED_CANDIDATE_LIMIT);
269
+ }
270
+ /** Read the dedicated EAV snapshot without creating `.oms` state. */
271
+ async function loadAxisSnapshot(vault) {
272
+ const dbPath = axisStorePath(vault);
273
+ if (!existsSync(dbPath))
274
+ return null;
275
+ const store = openAxisStore(dbPath, { readOnly: true });
276
+ try {
277
+ const storedSignature = store.sourceSignature();
278
+ if (storedSignature === null || storedSignature !== await nodeSourceSignature(vault))
279
+ return null;
280
+ return store.list();
281
+ }
282
+ finally {
283
+ store.close();
284
+ }
285
+ }
286
+ function observationValue(value) {
287
+ if (value instanceof Date)
288
+ return value.toISOString().toLowerCase();
289
+ return String(value).trim().toLowerCase();
290
+ }
291
+ function contractAxisType(type) {
292
+ switch (type) {
293
+ case "number":
294
+ return "number";
295
+ case "boolean":
296
+ case "checkbox":
297
+ return "boolean";
298
+ case "date":
299
+ case "datetime":
300
+ return "date";
301
+ case "text":
302
+ case "string":
303
+ case "select":
304
+ case "list":
305
+ case "multitext":
306
+ case "multi":
307
+ case "tags":
308
+ case "aliases":
309
+ case "file":
310
+ return "string";
311
+ default:
312
+ return undefined;
313
+ }
314
+ }
315
+ /**
316
+ * Compare live frontmatter/EAV observations with the tracked JSON contract.
317
+ * Missing fields are allowed (required-field validation owns that concern);
318
+ * an observed undeclared key, type, or closed-vocabulary value is drift.
319
+ */
320
+ function hasContractObservationDrift(contract, nodes, observations) {
321
+ if (contract === null)
322
+ return false;
323
+ const declared = new Set();
324
+ const types = new Map();
325
+ const allowed = new Map();
326
+ const addAllowed = (key, values) => {
327
+ if (values === undefined)
328
+ return;
329
+ const normalizedKey = key.trim().toLowerCase();
330
+ const target = allowed.get(normalizedKey) ?? new Set();
331
+ for (const item of values) {
332
+ const value = typeof item === "string"
333
+ ? item
334
+ : item && typeof item === "object" && typeof item.value === "string"
335
+ ? item.value
336
+ : undefined;
337
+ if (value !== undefined)
338
+ target.add(observationValue(value));
339
+ }
340
+ allowed.set(normalizedKey, target);
341
+ };
342
+ for (const axis of contract.axes) {
343
+ if (axis.kind !== "field")
344
+ continue;
345
+ const key = axis.key.trim().toLowerCase();
346
+ if (key === "concept")
347
+ continue;
348
+ declared.add(key);
349
+ types.set(key, axis.type);
350
+ addAllowed(key, axis.allowedValues);
351
+ }
352
+ for (const [key, type] of Object.entries(contract.types)) {
353
+ const normalizedKey = key.trim().toLowerCase();
354
+ if (normalizedKey === "concept")
355
+ continue;
356
+ declared.add(normalizedKey);
357
+ types.set(normalizedKey, type);
358
+ }
359
+ for (const [key, values] of Object.entries(contract.allowedValues)) {
360
+ const normalizedKey = key.trim().toLowerCase();
361
+ if (normalizedKey === "concept")
362
+ continue;
363
+ declared.add(normalizedKey);
364
+ addAllowed(normalizedKey, values);
365
+ }
366
+ const observedKeys = new Set();
367
+ const observedValues = [];
368
+ // Node axes are the live source of truth even when no EAV snapshot exists.
369
+ for (const node of nodes) {
370
+ for (const [key, rawValues] of Object.entries(node.axes)) {
371
+ const normalizedKey = key.trim().toLowerCase();
372
+ if (normalizedKey === "concept")
373
+ continue;
374
+ observedKeys.add(normalizedKey);
375
+ const values = Array.isArray(rawValues) ? rawValues : [rawValues];
376
+ for (const value of values) {
377
+ // String values can also represent YAML dates after node-cache
378
+ // serialisation, so only infer unambiguous primitive types here.
379
+ const valueType = typeof value === "number"
380
+ ? "number"
381
+ : typeof value === "boolean"
382
+ ? "boolean"
383
+ : undefined;
384
+ observedValues.push({ key: normalizedKey, value, valueType });
385
+ }
386
+ }
387
+ }
388
+ for (const row of observations ?? []) {
389
+ const normalizedKey = row.axisKey.trim().toLowerCase();
390
+ if (row.axisKind !== "field" || normalizedKey === "concept")
391
+ continue;
392
+ observedKeys.add(normalizedKey);
393
+ observedValues.push({
394
+ key: normalizedKey,
395
+ value: row.value,
396
+ valueType: row.valueType,
397
+ normalizedValue: row.normalizedValue,
398
+ });
399
+ }
400
+ for (const observed of observedValues) {
401
+ if (!declared.has(observed.key))
402
+ return true;
403
+ const expectedType = contractAxisType(types.get(observed.key) ?? "");
404
+ if (expectedType !== undefined && observed.valueType !== undefined && expectedType !== observed.valueType) {
405
+ return true;
406
+ }
407
+ const values = allowed.get(observed.key);
408
+ if (values !== undefined && !values.has(observed.normalizedValue ?? observationValue(observed.value))) {
409
+ return true;
410
+ }
411
+ }
412
+ for (const key of observedKeys) {
413
+ if (!declared.has(key))
414
+ return true;
415
+ }
416
+ return false;
417
+ }
418
+ async function contractObservationDrift(vault, nodes, observations) {
419
+ return hasContractObservationDrift(await loadContract(vault), nodes, observations);
420
+ }
421
+ /** Overlay typed EAV values onto the lexical node snapshot. */
422
+ function applyAxisSnapshot(nodes, observations) {
423
+ const byNote = new Map();
424
+ for (const observation of observations) {
425
+ const rows = byNote.get(observation.notePath) ?? [];
426
+ rows.push(observation);
427
+ byNote.set(observation.notePath, rows);
428
+ }
429
+ return nodes.map((node) => {
430
+ const rows = byNote.get(node.path);
431
+ if (rows === undefined)
432
+ return node;
433
+ const fields = {};
434
+ const links = [];
435
+ let folder = node.folder;
436
+ for (const row of rows) {
437
+ const value = row.value instanceof Date ? row.value.toISOString() : row.value;
438
+ if (row.axisKind === "folder" && typeof value === "string") {
439
+ folder = value;
440
+ }
441
+ else if (row.axisKind === "link" && typeof value === "string") {
442
+ links.push(value);
443
+ }
444
+ else if (row.axisKind === "field") {
445
+ const values = fields[row.axisKey] ?? [];
446
+ values.push(value);
447
+ fields[row.axisKey] = values;
448
+ }
449
+ }
450
+ return {
451
+ ...node,
452
+ folder,
453
+ // The node DTO keeps its legacy string-array declaration, while this
454
+ // runtime overlay deliberately carries typed EAV scalars.
455
+ axes: fields,
456
+ // An existing snapshot is authoritative: an empty link/field set must
457
+ // clear stale values from an older JSON node-index cache.
458
+ wikilinks: [...new Set(links)],
459
+ };
460
+ });
461
+ }
462
+ /** Contract field names are the allow-list for public field-axis queries. */
463
+ async function loadContractFieldKeys(vault) {
464
+ const contract = await loadContract(vault);
465
+ if (contract === null)
466
+ return undefined;
467
+ const keys = new Set();
468
+ for (const axis of contract.axes) {
469
+ if (axis.kind === "field")
470
+ keys.add(axis.key.trim().toLowerCase());
471
+ }
472
+ for (const key of Object.keys(contract.types))
473
+ keys.add(key.trim().toLowerCase());
474
+ return keys;
475
+ }
476
+ function validateKnownFieldAxes(axes, knownFields) {
477
+ if (axes === undefined || knownFields === undefined || axes.field === undefined)
478
+ return;
479
+ if (axes.field === null || typeof axes.field !== "object" || Array.isArray(axes.field)) {
480
+ throw new Error("Field axis must be an object mapping field names to values.");
481
+ }
482
+ for (const key of Object.keys(axes.field)) {
483
+ if (key.trim().toLocaleLowerCase() === "concept") {
484
+ throw new Error('Unknown query field axis "concept".');
485
+ }
486
+ if (!knownFields.has(key.trim().toLowerCase())) {
487
+ throw new Error(`Unknown field axis "${key}" (not declared by the JSON contract).`);
488
+ }
489
+ }
490
+ }
205
491
  // ---------------------------------------------------------------------------
206
492
  // Adapter facade
207
493
  // ---------------------------------------------------------------------------
@@ -217,6 +503,7 @@ export class McpEngineAdapter {
217
503
  config;
218
504
  reranker;
219
505
  implicitLexicalSync;
506
+ persistLexicalSync;
220
507
  /**
221
508
  * @param deps - Injected backend dependencies (store + embed required).
222
509
  * @param vaultPath - Absolute vault root, used by graph / sync / cleanup /
@@ -228,12 +515,13 @@ export class McpEngineAdapter {
228
515
  */
229
516
  constructor(deps, vaultPath, config, reranker,
230
517
  /** Explicit assembly policy; read-only engines suppress this refresh. */
231
- implicitLexicalSync = false) {
518
+ implicitLexicalSync = false, persistLexicalSync = true) {
232
519
  this.deps = deps;
233
520
  this.vaultPath = vaultPath;
234
521
  this.config = config;
235
522
  this.reranker = reranker;
236
523
  this.implicitLexicalSync = implicitLexicalSync;
524
+ this.persistLexicalSync = persistLexicalSync;
237
525
  }
238
526
  // -------------------------------------------------------------------------
239
527
  // Cache-path + node-index helpers
@@ -245,37 +533,167 @@ export class McpEngineAdapter {
245
533
  return path.join(vault, ".oms", "cache", "engine", "node-index.json");
246
534
  }
247
535
  /** Load the node index from cache, building it live on a cache miss. */
248
- async loadOrBuildNodes() {
249
- const cached = await loadNodeIndex(this.nodeCachePath(this.vaultPath));
536
+ async loadOrBuildNodes(vault = this.vaultPath) {
537
+ // A direct adapter caller may supply a store-backed query without a
538
+ // filesystem vault (for example an MCP transport test). Node enrichment is
539
+ // optional in that case; never turn a valid retrieval result into an ENOENT.
540
+ if (!existsSync(vault))
541
+ return [];
542
+ const cached = await loadNodeIndex(this.nodeCachePath(vault), await nodeSourceSignature(vault));
250
543
  if (cached !== null)
251
544
  return cached;
252
- return buildNodeIndex({ vaultPath: this.vaultPath });
545
+ return buildNodeIndex({ vaultPath: vault });
253
546
  }
254
547
  // -------------------------------------------------------------------------
255
548
  // 1. oms_semantic_query (centerpiece)
256
549
  // -------------------------------------------------------------------------
550
+ /**
551
+ * Evaluate an overview/axis query directly from the read-only node index.
552
+ * This path is intentionally model-free and does not open the embedding
553
+ * store, so a query issued outside a vault or against an empty vault cannot
554
+ * create `.oms/` state as a side effect.
555
+ */
556
+ async queryNodeAxes(opts, subQueries) {
557
+ const vault = opts.vault ?? this.vaultPath;
558
+ const baseNodes = await this.loadOrBuildNodes(vault);
559
+ const snapshot = await loadAxisSnapshot(vault);
560
+ const drift = await contractObservationDrift(vault, baseNodes, snapshot);
561
+ const observedFields = snapshot === null
562
+ ? undefined
563
+ : new Set(snapshot.filter((row) => row.axisKind === "field").map((row) => row.axisKey.toLowerCase()));
564
+ const liveObservedFields = new Set(baseNodes.flatMap((node) => Object.keys(node.axes).map((key) => key.toLowerCase())));
565
+ const knownFields = await loadContractFieldKeys(vault)
566
+ ?? (observedFields === undefined ? liveObservedFields : new Set([...observedFields, ...liveObservedFields]));
567
+ validateKnownFieldAxes(opts.axes, knownFields);
568
+ const nodes = snapshot === null ? baseNodes : applyAxisSnapshot(baseNodes, snapshot);
569
+ const axisFiltered = opts.axes === undefined
570
+ ? nodes
571
+ : filterNodesByQueryAxes(nodes, opts.axes);
572
+ const collection = opts.collectionPath;
573
+ const filtered = collection === undefined
574
+ ? axisFiltered
575
+ : axisFiltered.filter((node) => node.path === collection || node.path.startsWith(`${collection}/`));
576
+ const query = subQueries.find((subQuery) => subQuery.type === "lex")?.query
577
+ ?? (subQueries.length === 0 ? opts.query ?? "" : "");
578
+ const scored = filtered
579
+ .map((node) => {
580
+ const score = searchScore(node, query);
581
+ return {
582
+ docPath: node.path,
583
+ score,
584
+ ...(query.trim().length > 0 ? { perTypeScores: { lex: score } } : {}),
585
+ };
586
+ })
587
+ // An axis constraint narrows the corpus; it is not lexical evidence.
588
+ // Once a lexical query is supplied, do not return axis-only zero-score
589
+ // documents as query hits.
590
+ .filter((result) => query.trim().length === 0 || result.score > 0)
591
+ .sort((left, right) => right.score - left.score || left.docPath.localeCompare(right.docPath));
592
+ const facetNodes = opts.minScore === undefined
593
+ ? filtered
594
+ : filtered.filter((node) => searchScore(node, query) >= opts.minScore);
595
+ const intents = folderIntents(vault);
596
+ const facets = queryFacets(facetNodes).map((facet) => ({
597
+ ...facet,
598
+ intent: facetIntent(facet, intents, opts.intent),
599
+ }));
600
+ const candidateLimit = opts.candidateLimit ?? UNBOUNDED_CANDIDATE_LIMIT;
601
+ const candidates = scored.slice(0, candidateLimit);
602
+ const shouldRerank = opts.rerank === true && opts.noRerank !== true;
603
+ if (shouldRerank && this.reranker === undefined) {
604
+ return queryResultUnavailable("reranking requires a configured Reranker; pass one to assembleEngine() or createOMSMcpServer().");
605
+ }
606
+ if (shouldRerank && query.trim().length === 0) {
607
+ return queryResultUnavailable("reranking requires a non-empty natural-language query.");
608
+ }
609
+ const reranker = this.reranker;
610
+ const ranked = shouldRerank
611
+ ? await reranker.rerank(query, candidates.map((result) => ({
612
+ docPath: result.docPath,
613
+ chunkOrdinal: 0,
614
+ score: result.score,
615
+ }))).then((reranked) => {
616
+ const candidateByPath = new Map(candidates.map((candidate) => [candidate.docPath, candidate]));
617
+ return reranked.flatMap((hit) => {
618
+ const candidate = candidateByPath.get(hit.docPath);
619
+ return candidate === undefined
620
+ ? []
621
+ : [{ ...candidate, score: hit.score, perTypeScores: { lex: hit.score } }];
622
+ });
623
+ })
624
+ : candidates;
625
+ const hasNonLexSearch = subQueries.some((search) => search.type !== "lex");
626
+ const result = retrievalResultsToQueryResult(ranked, {
627
+ minScore: opts.minScore,
628
+ limit: resultPageLimit(opts),
629
+ cursor: opts.cursor,
630
+ intent: opts.intent,
631
+ facetValues: facets,
632
+ // Axis queries are evaluated by the model-free node matcher. A vector
633
+ // request is reported as approximated rather than falsely claiming vec
634
+ // evidence in the receipt.
635
+ usedChannels: query.trim().length > 0 ? ["lex"] : [],
636
+ approximated: hasNonLexSearch,
637
+ drift,
638
+ });
639
+ return enrichQueryHits(result, vault);
640
+ }
257
641
  /**
258
642
  * Execute a semantic query and return MCP-shaped results.
259
643
  * Maps opts → TypedSubQuery[] → dispatch() → McpSemanticQueryResult.
260
644
  */
261
645
  async semanticQuery(opts) {
262
- const subQueries = queryOptionsToSubQueries(opts);
263
- if (subQueries.length === 0) {
264
- return queryResultUnavailable("No sub-queries derived from options");
646
+ let normalized;
647
+ try {
648
+ normalized = normalizeQueryOptions(opts);
649
+ }
650
+ catch (err) {
651
+ return queryResultUnavailable(err instanceof Error ? err.message : String(err));
652
+ }
653
+ const { subQueries } = normalized;
654
+ if (opts.axes !== undefined && hasExplicitEmbeddingSearch(opts)) {
655
+ return queryResultUnavailable("Axis queries support lexical matching only; explicit vector/HyDE retrieval requires " +
656
+ "OMS_EMBEDDING_PROVIDER + OMS_EMBEDDING_MODEL.");
657
+ }
658
+ if (opts.axes !== undefined || normalized.overview) {
659
+ try {
660
+ return await this.queryNodeAxes(opts, subQueries);
661
+ }
662
+ catch (err) {
663
+ return queryResultUnavailable(err instanceof Error ? err.message : String(err));
664
+ }
265
665
  }
266
666
  try {
267
- if (this.implicitLexicalSync && isLexOnlySubQueries(subQueries)) {
667
+ // A core engine has a real lexical store but deliberately no embedding
668
+ // provider. The default query mode is hybrid for vector-capable engines;
669
+ // on a model-less engine, retain the useful lexical half unless the
670
+ // caller explicitly asked for vec/HyDE (which must fail loudly).
671
+ const vecAvailable = typeof this.deps.store.capabilities === "function"
672
+ ? this.deps.store.capabilities().vecAvailable
673
+ : true;
674
+ const modelLessFallback = !vecAvailable &&
675
+ !hasExplicitEmbeddingSearch(opts) &&
676
+ subQueries.some((subQuery) => subQuery.type === "vec");
677
+ const effectiveSubQueries = modelLessFallback
678
+ ? subQueries.filter((subQuery) => subQuery.type === "lex")
679
+ : subQueries;
680
+ if (this.implicitLexicalSync && isLexOnlySubQueries(effectiveSubQueries)) {
268
681
  const syncResult = await syncEngineStore({
269
682
  vault: opts.vault ?? this.vaultPath,
270
683
  collection: opts.collection,
271
684
  embed: false,
272
685
  store: this.deps.store,
686
+ persist: this.persistLexicalSync,
273
687
  });
274
688
  if (!syncResult.available) {
275
689
  return queryResultUnavailable(syncResult.reason ?? "Lexical index sync unavailable");
276
690
  }
277
691
  }
278
- const k = opts.candidateLimit ?? 20;
692
+ // Without an explicit candidate limit, retrieve the complete ranked
693
+ // stream so totalCount and offset cursors remain accurate on pages past
694
+ // the first 50 results. A caller-supplied candidateLimit remains an
695
+ // intentional cap (not an implementation truncation).
696
+ const k = opts.candidateLimit ?? UNBOUNDED_CANDIDATE_LIMIT;
279
697
  const shouldRerank = opts.rerank === true && opts.noRerank !== true;
280
698
  if (shouldRerank && this.reranker === undefined) {
281
699
  return queryResultUnavailable("reranking requires a configured Reranker; pass one to assembleEngine() or createOMSMcpServer().");
@@ -284,14 +702,38 @@ export class McpEngineAdapter {
284
702
  return queryResultUnavailable("reranking requires a non-empty natural-language query.");
285
703
  }
286
704
  const results = await retrieve({
287
- subQueries,
705
+ subQueries: [...effectiveSubQueries],
288
706
  deps: this.deps,
289
707
  k,
290
708
  collection: opts.collectionPath,
291
709
  query: opts.query,
292
710
  reranker: shouldRerank ? this.reranker : undefined,
293
711
  });
294
- const mapped = retrievalResultsToQueryResult(results, opts);
712
+ let facetValues;
713
+ const vault = opts.vault ?? this.vaultPath;
714
+ const baseNodes = await this.loadOrBuildNodes(vault);
715
+ const snapshot = await loadAxisSnapshot(vault);
716
+ const nodes = snapshot === null ? baseNodes : applyAxisSnapshot(baseNodes, snapshot);
717
+ const drift = await contractObservationDrift(vault, baseNodes, snapshot);
718
+ const scoped = opts.collectionPath === undefined
719
+ ? nodes
720
+ : nodes.filter((node) => node.path === opts.collectionPath || node.path.startsWith(`${opts.collectionPath}/`));
721
+ const intents = folderIntents(vault);
722
+ facetValues = queryFacets(scoped).map((facet) => ({
723
+ ...facet,
724
+ intent: facetIntent(facet, intents, opts.intent),
725
+ }));
726
+ const requestedChannels = [...new Set(effectiveSubQueries.map((search) => search.type))]
727
+ .filter((type) => type === "lex" || type === "vec" || type === "hyde");
728
+ const mapped = retrievalResultsToQueryResult(results, {
729
+ ...opts,
730
+ limit: resultPageLimit(opts),
731
+ facetValues,
732
+ usedChannels: requestedChannels,
733
+ approximated: modelLessFallback ||
734
+ effectiveSubQueries.some((search) => search.type !== "lex" && search.type !== "vec"),
735
+ drift,
736
+ });
295
737
  // Fill title + doc-head snippet from disk so engine hits reach practical
296
738
  // parity with the src/search preview (the pure mapper stays text-free).
297
739
  return enrichQueryHits(mapped, opts.vault ?? this.vaultPath);
@@ -314,6 +756,9 @@ export class McpEngineAdapter {
314
756
  * pool deduplicates by loadPromise per process so this is safe (RISK-1).
315
757
  */
316
758
  async syncEmbeddings(opts) {
759
+ const activeDbPath = this.config?.dbPath ??
760
+ path.join(path.resolve(opts.vault), ".oms", "engine-store.sqlite");
761
+ let swapHandleClosed = false;
317
762
  try {
318
763
  const syncResult = await syncEngineStore({
319
764
  vault: opts.vault,
@@ -325,8 +770,31 @@ export class McpEngineAdapter {
325
770
  // resolves the real provider and fails fast (ADR-007) if it is missing.
326
771
  embeddingProvider: this.config?.embeddingProvider,
327
772
  embeddingModel: this.config?.embeddingModel,
773
+ embeddingDescriptor: this.config?.embeddingDescriptor,
774
+ embeddingDimensions: this.config?.embeddingDimensions,
775
+ embeddingContext: this.config?.embeddingContext,
776
+ embeddingContextLength: this.config?.embeddingContextLength,
777
+ embeddingContextTokens: this.config?.embeddingContextTokens,
778
+ embeddingMrlDim: this.config?.embeddingMrlDim,
779
+ embeddingNormalization: this.config?.embeddingNormalization,
780
+ embeddingPrefixScheme: this.config?.embeddingPrefixScheme,
781
+ dbPath: this.config?.dbPath,
328
782
  embed: opts.embed ?? true,
329
783
  force: opts.force ?? false,
784
+ onGenerationSwapPrepare: () => {
785
+ // The adapter's store outlives one sync call. Close it before the
786
+ // active WAL/SHM sidecars are removed, not after rename.
787
+ swapHandleClosed = true;
788
+ this.deps.store.close();
789
+ },
790
+ onGenerationSwapComplete: () => {
791
+ if (!swapHandleClosed)
792
+ return;
793
+ const reboundStore = openEngineStore(activeDbPath, this.deps.embed.dimensions);
794
+ this.deps.store = reboundStore;
795
+ this.config?.onStoreRebind?.(reboundStore);
796
+ swapHandleClosed = false;
797
+ },
330
798
  });
331
799
  if (!syncResult.available) {
332
800
  return syncResultUnavailable(syncResult.reason ?? "sync unavailable", opts);
@@ -408,7 +876,11 @@ export class McpEngineAdapter {
408
876
  * longer exists in the live vault is cleared (meta + vec + FTS).
409
877
  */
410
878
  async cleanup(_opts) {
879
+ let releaseLock;
411
880
  try {
881
+ const dbPath = this.config?.dbPath ??
882
+ path.join(path.resolve(this.vaultPath), ".oms", "engine-store.sqlite");
883
+ releaseLock = acquireEngineStoreWriterLock(dbPath);
412
884
  const store = this.deps.store;
413
885
  const livePaths = new Set();
414
886
  for await (const rel of walkMarkdown(this.vaultPath, this.vaultPath)) {
@@ -433,6 +905,9 @@ export class McpEngineAdapter {
433
905
  catch (err) {
434
906
  return cleanupResultUnavailable(err instanceof Error ? err.message : String(err));
435
907
  }
908
+ finally {
909
+ releaseLock?.();
910
+ }
436
911
  }
437
912
  // -------------------------------------------------------------------------
438
913
  // 7. oms_graph_build
@@ -463,7 +938,12 @@ export class McpEngineAdapter {
463
938
  const edges = await buildGraph({ vaultPath: args.vaultPath });
464
939
  await saveCachedGraph(graphCachePath, edges);
465
940
  const nodes = await buildNodeIndex({ vaultPath: args.vaultPath });
466
- await saveNodeIndex(this.nodeCachePath(args.vaultPath), nodes);
941
+ const sourceSignature = await nodeSourceSignature(args.vaultPath);
942
+ await saveNodeIndex(this.nodeCachePath(args.vaultPath), nodes, sourceSignature);
943
+ // Graph build is the explicit writable refresh boundary for the typed
944
+ // EAV snapshot. Reconcile it atomically with the current markdown set.
945
+ const axisStore = await collectVaultAxisObservations(args.vaultPath);
946
+ axisStore.close();
467
947
  const noteSet = new Set(edges.flatMap((e) => [e.from, e.to]));
468
948
  return engineGraphBuildResultToMcp({
469
949
  notes: noteSet.size,
@@ -497,8 +977,11 @@ export class McpEngineAdapter {
497
977
  */
498
978
  async retrieveByAxis(filters) {
499
979
  try {
500
- const nodes = await this.loadOrBuildNodes();
501
- const limit = clamp(filters.limit ?? 10, 1, 50);
980
+ const baseNodes = await this.loadOrBuildNodes();
981
+ const snapshot = await loadAxisSnapshot(this.vaultPath);
982
+ const drift = await contractObservationDrift(this.vaultPath, baseNodes, snapshot);
983
+ const nodes = snapshot === null ? baseNodes : applyAxisSnapshot(baseNodes, snapshot);
984
+ const limit = Math.max(1, filters.limit ?? 10);
502
985
  const filtered = filterNodesByAxis(nodes, {
503
986
  concept: filters.concept,
504
987
  folder: filters.folder,
@@ -525,10 +1008,22 @@ export class McpEngineAdapter {
525
1008
  }),
526
1009
  evidence: { lexical: true, vector: false },
527
1010
  }));
528
- return { available: true, hits };
1011
+ const intents = folderIntents(this.vaultPath);
1012
+ const facets = queryFacets(filtered).map((facet) => ({
1013
+ ...facet,
1014
+ intent: facetIntent(facet, intents, undefined),
1015
+ }));
1016
+ return {
1017
+ available: true,
1018
+ hits,
1019
+ totalCount: filtered.length,
1020
+ facets,
1021
+ cursor: scored.length < filtered.length ? String(scored.length) : null,
1022
+ receipt: { usedChannels: ["lex"], approximated: false, drift },
1023
+ };
529
1024
  }
530
1025
  catch (err) {
531
- return { available: false, reason: err instanceof Error ? err.message : String(err), hits: [] };
1026
+ return queryResultUnavailable(err instanceof Error ? err.message : String(err));
532
1027
  }
533
1028
  }
534
1029
  // -------------------------------------------------------------------------