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
|
@@ -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 {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
501
|
-
const
|
|
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
|
-
|
|
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
|
|
1026
|
+
return queryResultUnavailable(err instanceof Error ? err.message : String(err));
|
|
532
1027
|
}
|
|
533
1028
|
}
|
|
534
1029
|
// -------------------------------------------------------------------------
|