@owrede/vault-memory 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -89,7 +89,7 @@ function extractResourceLength(handle) {
89
89
  if (firstSlash === -1) return 0;
90
90
  return afterScheme.length - (firstSlash + 1);
91
91
  }
92
- var ServerConfigSchema, VaultConfigSchema, BriefOllamaConfigSchema, BriefConfigSchema, ContractsMcpClientConfigSchema, ContractsConfigSchema, DEFAULT_CONTRACTS_CONFIG, PluginConfigSchema, DEFAULT_PLUGIN_CONFIG, MemorySinkConfigSchema, MemoryConfigSchema, AppConfigSchema, DEFAULT_CONFIG;
92
+ var ServerConfigSchema, ContextFitConfigSchema, VaultConfigSchema, BriefOllamaConfigSchema, BriefConfigSchema, ContractsMcpClientConfigSchema, ContractsConfigSchema, DEFAULT_CONTRACTS_CONFIG, PluginConfigSchema, DEFAULT_PLUGIN_CONFIG, MemorySinkConfigSchema, MemoryConfigSchema, AppConfigSchema, DEFAULT_CONFIG;
93
93
  var init_loader = __esm({
94
94
  "src/config/loader.ts"() {
95
95
  "use strict";
@@ -102,9 +102,17 @@ var init_loader = __esm({
102
102
  reranker_backend: z.enum(["onnx", "ollama"]).optional(),
103
103
  reranker_model_dir: z.string().optional()
104
104
  });
105
+ ContextFitConfigSchema = z.object({
106
+ command: z.string().min(1).optional(),
107
+ tokenizer: z.string().min(1).optional(),
108
+ method: z.enum(["exact", "bm25", "sid", "graph", "hierarchy", "hybrid"]).optional()
109
+ });
105
110
  VaultConfigSchema = z.object({
106
111
  name: z.string().min(1),
107
112
  path: z.string().min(1),
113
+ // ADR-008: retrieval engine. Omitted ⇒ "ollama" (back-compat default).
114
+ backend: z.enum(["ollama", "contextfit"]).optional(),
115
+ contextfit: ContextFitConfigSchema.optional(),
108
116
  embedding_model: z.string().optional(),
109
117
  secondary_embedding_model: z.string().optional(),
110
118
  write_enabled: z.boolean().optional(),
@@ -228,7 +236,8 @@ async function addVault(opts) {
228
236
  name: proposedName,
229
237
  path: resolvedPath,
230
238
  writeEnabled: opts.writeEnabled ?? false,
231
- excludeGlobs: opts.excludeGlobs ?? DEFAULT_EXCLUDE_GLOBS
239
+ excludeGlobs: opts.excludeGlobs ?? DEFAULT_EXCLUDE_GLOBS,
240
+ ...opts.backend ? { backend: opts.backend } : {}
232
241
  });
233
242
  await ensureFileExists(cfgFile);
234
243
  await appendToFile(cfgFile, block);
@@ -252,13 +261,21 @@ function renderVaultBlock(input) {
252
261
  `# Added by vault-memory add-vault on ${(/* @__PURE__ */ new Date()).toISOString()}`,
253
262
  "[[vaults]]",
254
263
  `name = ${JSON.stringify(input.name)}`,
255
- `path = ${JSON.stringify(input.path)}`,
264
+ `path = ${JSON.stringify(input.path)}`
265
+ ];
266
+ if (input.backend === "contextfit") {
267
+ lines.push(
268
+ `# ADR-008: CPU-only, token-native engine (no Ollama/embeddings/GPU).`,
269
+ `backend = "contextfit"`
270
+ );
271
+ }
272
+ lines.push(
256
273
  `write_enabled = ${input.writeEnabled}`,
257
274
  `exclude_globs = [`,
258
275
  ...input.excludeGlobs.map((g) => ` ${JSON.stringify(g)},`),
259
276
  `]`,
260
277
  ""
261
- ];
278
+ );
262
279
  return lines.join("\n");
263
280
  }
264
281
  async function ensureFileExists(path7) {
@@ -4922,6 +4939,290 @@ var init_hybrid = __esm({
4922
4939
  }
4923
4940
  });
4924
4941
 
4942
+ // src/adapters/retrieval/contextfit/cli.ts
4943
+ import spawn from "cross-spawn";
4944
+ function runContextFit(cfg, subcommandArgs, timeoutMs) {
4945
+ const globalArgs = ["--kb", cfg.kbPath];
4946
+ if (cfg.tokenizer) globalArgs.push("--tokenizer", cfg.tokenizer);
4947
+ const args2 = [...globalArgs, ...subcommandArgs];
4948
+ return new Promise((resolve7, reject) => {
4949
+ let child;
4950
+ try {
4951
+ child = spawn(cfg.command, args2, { stdio: ["pipe", "pipe", "pipe"] });
4952
+ } catch (err) {
4953
+ const e = err;
4954
+ reject(
4955
+ new ContextFitError(
4956
+ `contextfit spawn failed: ${e.message}`,
4957
+ e.code === "ENOENT" ? "ENOENT" : "NONZERO_EXIT"
4958
+ )
4959
+ );
4960
+ return;
4961
+ }
4962
+ child.stdin?.end();
4963
+ let stdout = "";
4964
+ let stderr = "";
4965
+ let settled = false;
4966
+ const timer = setTimeout(() => {
4967
+ if (settled) return;
4968
+ settled = true;
4969
+ child.kill("SIGKILL");
4970
+ reject(new ContextFitError(`contextfit timed out after ${timeoutMs}ms`, "TIMEOUT"));
4971
+ }, timeoutMs);
4972
+ child.stdout?.on("data", (d) => {
4973
+ stdout += d.toString();
4974
+ });
4975
+ child.stderr?.on("data", (d) => {
4976
+ stderr += d.toString();
4977
+ });
4978
+ child.on("error", (err) => {
4979
+ if (settled) return;
4980
+ settled = true;
4981
+ clearTimeout(timer);
4982
+ if (err.code === "ENOENT") {
4983
+ reject(
4984
+ new ContextFitError(
4985
+ `contextfit not found (tried '${cfg.command}'). Install it with \`pipx install contextfit\` (or pip), or set the command path.`,
4986
+ "ENOENT"
4987
+ )
4988
+ );
4989
+ } else {
4990
+ reject(new ContextFitError(`contextfit spawn failed: ${err.message}`));
4991
+ }
4992
+ });
4993
+ child.on("close", (codeNum) => {
4994
+ if (settled) return;
4995
+ settled = true;
4996
+ clearTimeout(timer);
4997
+ if (codeNum === 0) {
4998
+ resolve7({ stdout, stderr });
4999
+ } else {
5000
+ reject(
5001
+ new ContextFitError(
5002
+ `contextfit exited ${codeNum}: ${stderr.trim() || stdout.trim() || "(no output)"}`,
5003
+ "NONZERO_EXIT"
5004
+ )
5005
+ );
5006
+ }
5007
+ });
5008
+ });
5009
+ }
5010
+ async function runContextFitWithRetry(cfg, subcommandArgs, timeoutMs) {
5011
+ try {
5012
+ return await runContextFit(cfg, subcommandArgs, timeoutMs);
5013
+ } catch (err) {
5014
+ const isEbadf = err instanceof ContextFitError && /EBADF/.test(err.message);
5015
+ if (!isEbadf) throw err;
5016
+ await new Promise((r) => setTimeout(r, 50));
5017
+ return runContextFit(cfg, subcommandArgs, timeoutMs);
5018
+ }
5019
+ }
5020
+ async function contextFitIngest(cfg, source, opts = {}) {
5021
+ const args2 = ["ingest", source, "--rebuild-index-after-ingest"];
5022
+ if (opts.chunkSize !== void 0) args2.push("--chunk-size", String(opts.chunkSize));
5023
+ if (opts.overlap !== void 0) args2.push("--overlap", String(opts.overlap));
5024
+ const { stdout } = await runContextFitWithRetry(cfg, args2, cfg.timeoutMs ?? 6e5);
5025
+ return stdout;
5026
+ }
5027
+ async function contextFitQuery(cfg, query, opts = {}) {
5028
+ const args2 = ["query", query, "--json"];
5029
+ if (opts.topK !== void 0) args2.push("--top-k", String(opts.topK));
5030
+ if (opts.method !== void 0) args2.push("--method", opts.method);
5031
+ const { stdout } = await runContextFitWithRetry(cfg, args2, cfg.timeoutMs ?? 3e4);
5032
+ return parseQueryOutput(stdout);
5033
+ }
5034
+ function parseQueryOutput(stdout) {
5035
+ const start = stdout.indexOf("{");
5036
+ if (start === -1) {
5037
+ throw new ContextFitError(
5038
+ `contextfit query produced no JSON: ${stdout.slice(0, 200)}`,
5039
+ "BAD_JSON"
5040
+ );
5041
+ }
5042
+ let parsed;
5043
+ try {
5044
+ parsed = JSON.parse(stdout.slice(start));
5045
+ } catch (err) {
5046
+ const msg = err instanceof Error ? err.message : String(err);
5047
+ throw new ContextFitError(`contextfit query JSON parse failed: ${msg}`, "BAD_JSON");
5048
+ }
5049
+ const obj = parsed;
5050
+ if (!Array.isArray(obj.chunks)) {
5051
+ throw new ContextFitError(
5052
+ `contextfit query JSON missing 'chunks' array (got keys: ${Object.keys(obj ?? {}).join(", ")})`,
5053
+ "BAD_JSON"
5054
+ );
5055
+ }
5056
+ return {
5057
+ query: typeof obj.query === "string" ? obj.query : "",
5058
+ method: typeof obj.method === "string" ? obj.method : "hybrid",
5059
+ retrieved_chunks: typeof obj.retrieved_chunks === "number" ? obj.retrieved_chunks : obj.chunks.length,
5060
+ chunks: obj.chunks
5061
+ };
5062
+ }
5063
+ async function contextFitProbe(cfg) {
5064
+ try {
5065
+ await new Promise((resolve7, reject) => {
5066
+ const child = spawn(cfg.command, ["--help"], { stdio: ["pipe", "pipe", "pipe"] });
5067
+ child.stdin?.end();
5068
+ child.on("error", reject);
5069
+ child.on(
5070
+ "close",
5071
+ (c) => c === 0 ? resolve7() : reject(new Error(`exit ${c}`))
5072
+ );
5073
+ });
5074
+ return true;
5075
+ } catch {
5076
+ return false;
5077
+ }
5078
+ }
5079
+ var ContextFitError;
5080
+ var init_cli = __esm({
5081
+ "src/adapters/retrieval/contextfit/cli.ts"() {
5082
+ "use strict";
5083
+ init_esm_shims();
5084
+ ContextFitError = class extends Error {
5085
+ constructor(message, code = "NONZERO_EXIT") {
5086
+ super(message);
5087
+ this.code = code;
5088
+ }
5089
+ code;
5090
+ name = "ContextFitError";
5091
+ };
5092
+ }
5093
+ });
5094
+
5095
+ // src/adapters/retrieval/contextfit/index.ts
5096
+ var contextfit_exports = {};
5097
+ __export(contextfit_exports, {
5098
+ cliConfigForVault: () => cliConfigForVault,
5099
+ contextFitKbDir: () => contextFitKbDir,
5100
+ indexVaultWithContextFit: () => indexVaultWithContextFit,
5101
+ searchVaultWithContextFit: () => searchVaultWithContextFit,
5102
+ sourceToNotePath: () => sourceToNotePath
5103
+ });
5104
+ import { homedir as homedir4 } from "os";
5105
+ import { rm } from "fs/promises";
5106
+ import { join as join4, relative, isAbsolute } from "path";
5107
+ function contextFitKbDir(vaultName) {
5108
+ return join4(homedir4(), ".vault-memory", "contextfit", vaultName);
5109
+ }
5110
+ function cliConfigForVault(vault) {
5111
+ const cfg = {
5112
+ command: vault.contextfit?.command ?? DEFAULT_COMMAND,
5113
+ kbPath: contextFitKbDir(vault.name)
5114
+ };
5115
+ if (vault.contextfit?.tokenizer) cfg.tokenizer = vault.contextfit.tokenizer;
5116
+ return cfg;
5117
+ }
5118
+ async function indexVaultWithContextFit(vault, opts = {}) {
5119
+ const log = opts.onProgress ?? (() => {
5120
+ });
5121
+ const cfg = cliConfigForVault(vault);
5122
+ const start = Date.now();
5123
+ log(`ContextFit: ingesting ${vault.path} \u2192 ${cfg.kbPath}`);
5124
+ const available = await contextFitProbe({ command: cfg.command });
5125
+ if (!available) {
5126
+ return {
5127
+ status: "failed",
5128
+ stats: "",
5129
+ durationMs: Date.now() - start,
5130
+ error: `ContextFit CLI not runnable (tried '${cfg.command}'). Install with \`pipx install contextfit\` or set [[vaults]].contextfit.command.`
5131
+ };
5132
+ }
5133
+ try {
5134
+ await rm(cfg.kbPath, { recursive: true, force: true });
5135
+ const stats = await contextFitIngest(cfg, vault.path);
5136
+ log(stats.trim().split("\n").slice(-3).join(" \xB7 "));
5137
+ return { status: "completed", stats, durationMs: Date.now() - start };
5138
+ } catch (err) {
5139
+ const message = err instanceof Error ? err.message : String(err);
5140
+ return { status: "failed", stats: "", durationMs: Date.now() - start, error: message };
5141
+ }
5142
+ }
5143
+ function sourceToNotePath(source, vaultPath) {
5144
+ if (!source) return null;
5145
+ const rel = isAbsolute(source) ? relative(vaultPath, source) : source;
5146
+ if (rel.startsWith("..")) return null;
5147
+ return rel.split(/[\\/]/).join("/");
5148
+ }
5149
+ function chunkToHit(chunk, vault) {
5150
+ const notePath = sourceToNotePath(chunk.metadata?.source, vault.path);
5151
+ if (notePath === null) return null;
5152
+ const base = notePath.split("/").pop() ?? notePath;
5153
+ const noteTitle = base.replace(/\.md$/i, "");
5154
+ const hit = {
5155
+ vault: vault.name,
5156
+ notePath,
5157
+ noteTitle,
5158
+ chunkText: chunk.preview ?? "",
5159
+ chunkIdx: chunk.chunk_id,
5160
+ headingPath: null,
5161
+ score: chunk.score,
5162
+ scoreBreakdown: { contextfit: chunk.score }
5163
+ };
5164
+ return hit;
5165
+ }
5166
+ async function searchVaultWithContextFit(vault, query, opts = {}) {
5167
+ const cfg = cliConfigForVault(vault);
5168
+ const method = vault.contextfit?.method ?? "hybrid";
5169
+ const result = await contextFitQuery(cfg, query, {
5170
+ topK: opts.topK ?? 10,
5171
+ method
5172
+ });
5173
+ const hits = [];
5174
+ for (const chunk of result.chunks) {
5175
+ const hit = chunkToHit(chunk, vault);
5176
+ if (hit) hits.push(hit);
5177
+ }
5178
+ return hits;
5179
+ }
5180
+ var DEFAULT_COMMAND;
5181
+ var init_contextfit = __esm({
5182
+ "src/adapters/retrieval/contextfit/index.ts"() {
5183
+ "use strict";
5184
+ init_esm_shims();
5185
+ init_cli();
5186
+ DEFAULT_COMMAND = "contextfit";
5187
+ }
5188
+ });
5189
+
5190
+ // src/search/dispatch.ts
5191
+ function isContextFit(vault) {
5192
+ return vault.config.backend === "contextfit";
5193
+ }
5194
+ async function searchVaults(opts) {
5195
+ const topK = opts.topK ?? 10;
5196
+ const cfVaults = opts.vaults.filter(isContextFit);
5197
+ const ollamaVaults = opts.vaults.filter((v) => !isContextFit(v));
5198
+ if (cfVaults.length === 0) {
5199
+ return hybridSearch(opts);
5200
+ }
5201
+ const { searchVaultWithContextFit: searchVaultWithContextFit2 } = await Promise.resolve().then(() => (init_contextfit(), contextfit_exports));
5202
+ const cfPromise = Promise.all(
5203
+ cfVaults.map(
5204
+ (v) => searchVaultWithContextFit2(v.config, opts.query, { topK }).catch((err) => {
5205
+ const msg = err instanceof Error ? err.message : String(err);
5206
+ console.error(`[search:${v.config.name}] ContextFit query failed: ${msg}`);
5207
+ return [];
5208
+ })
5209
+ )
5210
+ );
5211
+ const ollamaPromise = ollamaVaults.length > 0 ? hybridSearch({ ...opts, vaults: ollamaVaults }) : Promise.resolve([]);
5212
+ const [cfResultsNested, ollamaResults] = await Promise.all([cfPromise, ollamaPromise]);
5213
+ const cfResults = cfResultsNested.flat();
5214
+ const merged = [...ollamaResults, ...cfResults];
5215
+ merged.sort((a, b) => b.score - a.score);
5216
+ return merged.slice(0, topK);
5217
+ }
5218
+ var init_dispatch = __esm({
5219
+ "src/search/dispatch.ts"() {
5220
+ "use strict";
5221
+ init_esm_shims();
5222
+ init_hybrid();
5223
+ }
5224
+ });
5225
+
4925
5226
  // src/search/glob.ts
4926
5227
  function compile(pattern) {
4927
5228
  const cached = cache.get(pattern);
@@ -4969,6 +5270,7 @@ var init_search = __esm({
4969
5270
  "use strict";
4970
5271
  init_esm_shims();
4971
5272
  init_hybrid();
5273
+ init_dispatch();
4972
5274
  init_glob();
4973
5275
  }
4974
5276
  });
@@ -5014,7 +5316,7 @@ var init_reranker = __esm({
5014
5316
  // src/rerank/onnx-reranker.ts
5015
5317
  import { readFile as readFile3 } from "fs/promises";
5016
5318
  import { existsSync } from "fs";
5017
- import { join as join4 } from "path";
5319
+ import { join as join5 } from "path";
5018
5320
  function sigmoid(x) {
5019
5321
  return 1 / (1 + Math.exp(-x));
5020
5322
  }
@@ -5092,8 +5394,8 @@ var init_onnx_reranker = __esm({
5092
5394
  if (this.loaded) return this.loaded;
5093
5395
  if (this.loading) return this.loading;
5094
5396
  this.loading = (async () => {
5095
- const modelPath = join4(this.modelDir, "model_quantized.onnx");
5096
- const tokenizerPath = join4(this.modelDir, "tokenizer.json");
5397
+ const modelPath = join5(this.modelDir, "model_quantized.onnx");
5398
+ const tokenizerPath = join5(this.modelDir, "tokenizer.json");
5097
5399
  if (!existsSync(modelPath)) {
5098
5400
  throw new Error(
5099
5401
  `OnnxReranker: model file not found at ${modelPath}. Run: curl -L https://huggingface.co/onnx-community/bge-reranker-v2-m3-ONNX/resolve/main/onnx/model_quantized.onnx -o ${modelPath}`
@@ -6419,53 +6721,62 @@ async function indexVault(vault, options) {
6419
6721
  const mode = options.mode ?? "incremental";
6420
6722
  const log = options.onProgress ?? (() => {
6421
6723
  });
6422
- log(`Probing Ollama model: ${options.embeddingModel}`);
6423
- const health = await options.ollama.healthCheck();
6424
- if (!health.ok) {
6425
- throw new Error(`Ollama unreachable: ${health.error ?? "unknown error"}`);
6426
- }
6427
- const modelExists = await options.ollama.modelExists(options.embeddingModel);
6428
- if (!modelExists) {
6429
- throw new Error(
6430
- `Embedding model "${options.embeddingModel}" not found in Ollama. Available: ${health.models?.join(", ") ?? "(none)"}. Run: ollama pull ${options.embeddingModel}`
6431
- );
6432
- }
6433
- const probe = await options.ollama.embed({
6434
- model: options.embeddingModel,
6435
- texts: ["probe"]
6436
- });
6437
- const dim = probe.dim;
6438
- const modelRow = vault.db.models.upsert({
6439
- name: options.embeddingModel,
6440
- provider: "ollama",
6441
- dim
6442
- });
6724
+ const embedMode = options.embeddings ?? "ollama";
6725
+ const ollama = options.ollama;
6726
+ let dim = 0;
6727
+ let modelRow = null;
6443
6728
  let secondaryModelRow = null;
6444
- if (options.secondaryEmbeddingModel) {
6445
- const secName = options.secondaryEmbeddingModel;
6446
- log(`Probing secondary (shadow) model: ${secName}`);
6447
- const secExists = await options.ollama.modelExists(secName);
6448
- if (!secExists) {
6729
+ if (embedMode === "ollama") {
6730
+ if (!ollama) {
6731
+ throw new Error("indexVault: embeddings='ollama' requires an OllamaClient (options.ollama).");
6732
+ }
6733
+ log(`Probing Ollama model: ${options.embeddingModel}`);
6734
+ const health = await ollama.healthCheck();
6735
+ if (!health.ok) {
6736
+ throw new Error(`Ollama unreachable: ${health.error ?? "unknown error"}`);
6737
+ }
6738
+ const modelExists = await ollama.modelExists(options.embeddingModel);
6739
+ if (!modelExists) {
6449
6740
  throw new Error(
6450
- `Secondary embedding model "${secName}" not found in Ollama. Run: ollama pull ${secName}`
6741
+ `Embedding model "${options.embeddingModel}" not found in Ollama. Available: ${health.models?.join(", ") ?? "(none)"}. Run: ollama pull ${options.embeddingModel}`
6451
6742
  );
6452
6743
  }
6453
- const secProbe = await options.ollama.embed({
6454
- model: secName,
6744
+ const probe = await ollama.embed({
6745
+ model: options.embeddingModel,
6455
6746
  texts: ["probe"]
6456
6747
  });
6457
- const row = vault.db.models.upsert({
6458
- name: secName,
6748
+ dim = probe.dim;
6749
+ modelRow = vault.db.models.upsert({
6750
+ name: options.embeddingModel,
6459
6751
  provider: "ollama",
6460
- dim: secProbe.dim,
6461
- active: false
6752
+ dim
6462
6753
  });
6463
- secondaryModelRow = { id: row.id, dim: row.dim };
6754
+ if (options.secondaryEmbeddingModel) {
6755
+ const secName = options.secondaryEmbeddingModel;
6756
+ log(`Probing secondary (shadow) model: ${secName}`);
6757
+ const secExists = await ollama.modelExists(secName);
6758
+ if (!secExists) {
6759
+ throw new Error(
6760
+ `Secondary embedding model "${secName}" not found in Ollama. Run: ollama pull ${secName}`
6761
+ );
6762
+ }
6763
+ const secProbe = await ollama.embed({
6764
+ model: secName,
6765
+ texts: ["probe"]
6766
+ });
6767
+ const row = vault.db.models.upsert({
6768
+ name: secName,
6769
+ provider: "ollama",
6770
+ dim: secProbe.dim,
6771
+ active: false
6772
+ });
6773
+ secondaryModelRow = { id: row.id, dim: row.dim };
6774
+ }
6464
6775
  }
6465
6776
  vault.db.audit.startRun({
6466
6777
  runId,
6467
6778
  vaultName: vault.config.name,
6468
- modelId: modelRow.id,
6779
+ modelId: modelRow?.id ?? null,
6469
6780
  trigger: mode === "full" ? "manual-full" : "manual-incremental"
6470
6781
  });
6471
6782
  let notesIndexed = 0;
@@ -6556,36 +6867,38 @@ async function indexVault(vault, options) {
6556
6867
  `[indexer:${vault.config.name}] section build failed for ${parsed.relativePath}: ${message} \u2014 skipping sections for this note`
6557
6868
  );
6558
6869
  }
6559
- const embedResult = await options.ollama.embed({
6560
- model: options.embeddingModel,
6561
- texts: chunks.map((c) => c.text)
6562
- });
6563
- if (embedResult.dim !== dim) {
6564
- throw new Error(`Embedding dimension mismatch: expected ${dim}, got ${embedResult.dim}`);
6565
- }
6566
- const embeddingInputs = chunkIds.map((chunkId, i) => ({
6567
- chunkId,
6568
- modelId: modelRow.id,
6569
- vector: embedResult.vectors[i]
6570
- }));
6571
- vault.db.embeddings.insertBatch(embeddingInputs);
6572
- if (secondaryModelRow) {
6573
- const secEmbed = await options.ollama.embed({
6574
- model: options.secondaryEmbeddingModel,
6870
+ if (embedMode === "ollama") {
6871
+ const embedResult = await ollama.embed({
6872
+ model: options.embeddingModel,
6575
6873
  texts: chunks.map((c) => c.text)
6576
6874
  });
6577
- if (secEmbed.dim !== secondaryModelRow.dim) {
6578
- throw new Error(
6579
- `Secondary embedding dimension mismatch: expected ${secondaryModelRow.dim}, got ${secEmbed.dim}`
6875
+ if (embedResult.dim !== dim) {
6876
+ throw new Error(`Embedding dimension mismatch: expected ${dim}, got ${embedResult.dim}`);
6877
+ }
6878
+ const embeddingInputs = chunkIds.map((chunkId, i) => ({
6879
+ chunkId,
6880
+ modelId: modelRow.id,
6881
+ vector: embedResult.vectors[i]
6882
+ }));
6883
+ vault.db.embeddings.insertBatch(embeddingInputs);
6884
+ if (secondaryModelRow) {
6885
+ const secEmbed = await ollama.embed({
6886
+ model: options.secondaryEmbeddingModel,
6887
+ texts: chunks.map((c) => c.text)
6888
+ });
6889
+ if (secEmbed.dim !== secondaryModelRow.dim) {
6890
+ throw new Error(
6891
+ `Secondary embedding dimension mismatch: expected ${secondaryModelRow.dim}, got ${secEmbed.dim}`
6892
+ );
6893
+ }
6894
+ vault.db.embeddings.insertBatch(
6895
+ chunkIds.map((chunkId, i) => ({
6896
+ chunkId,
6897
+ modelId: secondaryModelRow.id,
6898
+ vector: secEmbed.vectors[i]
6899
+ }))
6580
6900
  );
6581
6901
  }
6582
- vault.db.embeddings.insertBatch(
6583
- chunkIds.map((chunkId, i) => ({
6584
- chunkId,
6585
- modelId: secondaryModelRow.id,
6586
- vector: secEmbed.vectors[i]
6587
- }))
6588
- );
6589
6902
  }
6590
6903
  insertWikilinks(vault, noteId, parsed.wikilinks, firstPassResolver);
6591
6904
  writeAllEdges(vault, noteId, parsed, firstPassResolver);
@@ -6850,16 +7163,24 @@ async function indexNote(options) {
6850
7163
  isNew: false
6851
7164
  };
6852
7165
  }
6853
- const activeModel = vault.db.models.getActive();
6854
- if (!activeModel) {
6855
- throw new Error(
6856
- `single-indexer: no active embedding model in DB. Run a full index first to register "${embeddingModel}".`
6857
- );
6858
- }
6859
- if (activeModel.name !== embeddingModel) {
6860
- throw new Error(
6861
- `single-indexer: active model "${activeModel.name}" does not match requested "${embeddingModel}". Run a full re-index to switch models.`
6862
- );
7166
+ const embedMode = options.embeddings ?? "ollama";
7167
+ let activeModel = null;
7168
+ if (embedMode === "ollama") {
7169
+ if (!ollama) {
7170
+ throw new Error("single-indexer: embeddings='ollama' requires an OllamaClient.");
7171
+ }
7172
+ const am = vault.db.models.getActive();
7173
+ if (!am) {
7174
+ throw new Error(
7175
+ `single-indexer: no active embedding model in DB. Run a full index first to register "${embeddingModel}".`
7176
+ );
7177
+ }
7178
+ if (am.name !== embeddingModel) {
7179
+ throw new Error(
7180
+ `single-indexer: active model "${am.name}" does not match requested "${embeddingModel}". Run a full re-index to switch models.`
7181
+ );
7182
+ }
7183
+ activeModel = am;
6863
7184
  }
6864
7185
  const upsert = vault.db.notes.upsertByPath({
6865
7186
  path: parsed.relativePath,
@@ -6872,6 +7193,7 @@ async function indexNote(options) {
6872
7193
  wordCount: parsed.wordCount
6873
7194
  });
6874
7195
  vault.db.aliases.setForNote(upsert.id, extractAliases(parsed.frontmatter));
7196
+ vault.db.sections.deleteByNote(upsert.id);
6875
7197
  vault.db.chunks.deleteByNote(upsert.id);
6876
7198
  vault.db.wikilinks.deleteByNote(upsert.id);
6877
7199
  vault.db.edges.deleteByNote(upsert.id);
@@ -6901,41 +7223,52 @@ async function indexNote(options) {
6901
7223
  chunkIdFragment: computeChunkIdFragment(c.text)
6902
7224
  }))
6903
7225
  );
6904
- const embedResult = await ollama.embed({
6905
- model: embeddingModel,
6906
- texts: chunks.map((c) => c.text)
6907
- });
6908
- if (embedResult.dim !== activeModel.dim) {
6909
- throw new Error(
6910
- `single-indexer: embedding dim ${embedResult.dim} does not match registered dim ${activeModel.dim} for model "${embeddingModel}".`
7226
+ try {
7227
+ buildSectionsForNote(vault, upsert.id, parsed.content, chunkIds);
7228
+ } catch (err) {
7229
+ const message = err instanceof Error ? err.message : String(err);
7230
+ process.stderr.write(
7231
+ `[single-indexer:${vault.config.name}] section build failed for ${parsed.relativePath}: ${message}
7232
+ `
6911
7233
  );
6912
7234
  }
6913
- vault.db.embeddings.insertBatch(
6914
- chunkIds.map((chunkId, i) => ({
6915
- chunkId,
6916
- modelId: activeModel.id,
6917
- vector: embedResult.vectors[i]
6918
- }))
6919
- );
6920
- if (secondaryName) {
6921
- const secondaryModel = vault.db.models.getByName(secondaryName);
6922
- if (secondaryModel && secondaryModel.id !== activeModel.id) {
6923
- const secEmbed = await ollama.embed({
6924
- model: secondaryName,
6925
- texts: chunks.map((c) => c.text)
6926
- });
6927
- if (secEmbed.dim !== secondaryModel.dim) {
6928
- throw new Error(
6929
- `single-indexer: shadow embedding dim ${secEmbed.dim} does not match registered dim ${secondaryModel.dim} for "${secondaryName}".`
7235
+ if (embedMode === "ollama") {
7236
+ const embedResult = await ollama.embed({
7237
+ model: embeddingModel,
7238
+ texts: chunks.map((c) => c.text)
7239
+ });
7240
+ if (embedResult.dim !== activeModel.dim) {
7241
+ throw new Error(
7242
+ `single-indexer: embedding dim ${embedResult.dim} does not match registered dim ${activeModel.dim} for model "${embeddingModel}".`
7243
+ );
7244
+ }
7245
+ vault.db.embeddings.insertBatch(
7246
+ chunkIds.map((chunkId, i) => ({
7247
+ chunkId,
7248
+ modelId: activeModel.id,
7249
+ vector: embedResult.vectors[i]
7250
+ }))
7251
+ );
7252
+ if (secondaryName) {
7253
+ const secondaryModel = vault.db.models.getByName(secondaryName);
7254
+ if (secondaryModel && secondaryModel.id !== activeModel.id) {
7255
+ const secEmbed = await ollama.embed({
7256
+ model: secondaryName,
7257
+ texts: chunks.map((c) => c.text)
7258
+ });
7259
+ if (secEmbed.dim !== secondaryModel.dim) {
7260
+ throw new Error(
7261
+ `single-indexer: shadow embedding dim ${secEmbed.dim} does not match registered dim ${secondaryModel.dim} for "${secondaryName}".`
7262
+ );
7263
+ }
7264
+ vault.db.embeddings.insertBatch(
7265
+ chunkIds.map((chunkId, i) => ({
7266
+ chunkId,
7267
+ modelId: secondaryModel.id,
7268
+ vector: secEmbed.vectors[i]
7269
+ }))
6930
7270
  );
6931
7271
  }
6932
- vault.db.embeddings.insertBatch(
6933
- chunkIds.map((chunkId, i) => ({
6934
- chunkId,
6935
- modelId: secondaryModel.id,
6936
- vector: secEmbed.vectors[i]
6937
- }))
6938
- );
6939
7272
  }
6940
7273
  }
6941
7274
  insertWikilinks2(vault, upsert.id, parsed.wikilinks);
@@ -7027,6 +7360,7 @@ async function catchupVault(options) {
7027
7360
  });
7028
7361
  let reindexed = 0;
7029
7362
  const knownPaths = /* @__PURE__ */ new Set();
7363
+ const isContextFit2 = vault.config.backend === "contextfit";
7030
7364
  for (const file of files) {
7031
7365
  const parsed = await parseNote(file, vault.config.path).catch(() => null);
7032
7366
  if (!parsed) continue;
@@ -7039,7 +7373,7 @@ async function catchupVault(options) {
7039
7373
  vault,
7040
7374
  absolutePath: file,
7041
7375
  embeddingModel: options.embeddingModel,
7042
- ollama: options.ollama
7376
+ ...isContextFit2 ? { embeddings: "none" } : { ollama: options.ollama }
7043
7377
  });
7044
7378
  if (result.status === "indexed") {
7045
7379
  reindexed++;
@@ -7056,6 +7390,13 @@ async function catchupVault(options) {
7056
7390
  }
7057
7391
  }
7058
7392
  }
7393
+ if (isContextFit2 && (reindexed > 0 || removed > 0)) {
7394
+ const { indexVaultWithContextFit: indexVaultWithContextFit2 } = await Promise.resolve().then(() => (init_contextfit(), contextfit_exports));
7395
+ const r = await indexVaultWithContextFit2(vault.config, { onProgress: log });
7396
+ log(
7397
+ r.status === "completed" ? `catch-up: ContextFit KB rebuilt (${r.durationMs}ms)` : `catch-up: ContextFit KB rebuild failed: ${r.error}`
7398
+ );
7399
+ }
7059
7400
  return {
7060
7401
  scanned: files.length,
7061
7402
  reindexed,
@@ -7063,9 +7404,9 @@ async function catchupVault(options) {
7063
7404
  durationMs: Date.now() - started
7064
7405
  };
7065
7406
  }
7066
- function joinAbs(root, relative5) {
7067
- if (root.endsWith("/")) return `${root}${relative5}`;
7068
- return `${root}/${relative5}`;
7407
+ function joinAbs(root, relative6) {
7408
+ if (root.endsWith("/")) return `${root}${relative6}`;
7409
+ return `${root}/${relative6}`;
7069
7410
  }
7070
7411
  var init_catchup = __esm({
7071
7412
  "src/indexer/catchup.ts"() {
@@ -7317,10 +7658,10 @@ var init_indexer2 = __esm({
7317
7658
 
7318
7659
  // src/adapters/delivery/obsidian-fs/fs.ts
7319
7660
  import { promises as fs5 } from "fs";
7320
- import { dirname, isAbsolute, resolve as resolve6, sep as sep5 } from "path";
7661
+ import { dirname, isAbsolute as isAbsolute2, resolve as resolve6, sep as sep5 } from "path";
7321
7662
  import { randomBytes } from "crypto";
7322
7663
  async function atomicWriteFile(absPath, content) {
7323
- if (!isAbsolute(absPath)) {
7664
+ if (!isAbsolute2(absPath)) {
7324
7665
  throw new Error(`atomicWriteFile requires an absolute path: ${absPath}`);
7325
7666
  }
7326
7667
  const parent = dirname(absPath);
@@ -7342,7 +7683,7 @@ async function safeJoinInsideVault(vaultRoot, relativePath) {
7342
7683
  if (typeof relativePath !== "string" || relativePath.length === 0) {
7343
7684
  throw new OutsideVaultError(relativePath, vaultRoot);
7344
7685
  }
7345
- if (isAbsolute(relativePath)) {
7686
+ if (isAbsolute2(relativePath)) {
7346
7687
  throw new OutsideVaultError(relativePath, vaultRoot);
7347
7688
  }
7348
7689
  const root = resolve6(vaultRoot);
@@ -9902,14 +10243,14 @@ var init_get = __esm({
9902
10243
 
9903
10244
  // src/brief/lock.ts
9904
10245
  import { open, readFile as readFile5, unlink, mkdir as mkdir2 } from "fs/promises";
9905
- import { homedir as homedir4 } from "os";
9906
- import { join as join6 } from "path";
10246
+ import { homedir as homedir5 } from "os";
10247
+ import { join as join7 } from "path";
9907
10248
  function lockDir(rootOverride) {
9908
- if (rootOverride !== void 0) return join6(rootOverride, "locks");
9909
- return join6(homedir4(), ".vault-memory", "locks");
10249
+ if (rootOverride !== void 0) return join7(rootOverride, "locks");
10250
+ return join7(homedir5(), ".vault-memory", "locks");
9910
10251
  }
9911
10252
  function lockPath(vaultName, rootOverride) {
9912
- return join6(lockDir(rootOverride), `${vaultName}.lock`);
10253
+ return join7(lockDir(rootOverride), `${vaultName}.lock`);
9913
10254
  }
9914
10255
  function isProcessAlive(pid) {
9915
10256
  try {
@@ -10746,6 +11087,9 @@ var init_watcher = __esm({
10746
11087
  queue;
10747
11088
  opts;
10748
11089
  started = false;
11090
+ /** ADR-008: debounce timer for ContextFit KB re-ingest (coalesces bursts). */
11091
+ cfReingestTimer = null;
11092
+ cfReingestInFlight = false;
10749
11093
  constructor(options) {
10750
11094
  this.opts = {
10751
11095
  vault: options.vault,
@@ -10793,12 +11137,51 @@ var init_watcher = __esm({
10793
11137
  if (!this.started) return;
10794
11138
  this.started = false;
10795
11139
  this.queue.shutdown();
11140
+ if (this.cfReingestTimer) {
11141
+ clearTimeout(this.cfReingestTimer);
11142
+ this.cfReingestTimer = null;
11143
+ }
10796
11144
  if (this.fsWatcher) {
10797
11145
  await this.fsWatcher.close();
10798
11146
  this.fsWatcher = null;
10799
11147
  }
10800
11148
  }
10801
11149
  // ─── internal ──────────────────────────────────────────────────────────
11150
+ /**
11151
+ * ADR-008: schedule a debounced full ContextFit KB re-ingest. Per-note
11152
+ * changes update the SQLite layer immediately (via indexNote); the ContextFit
11153
+ * search KB is rebuilt in one coalesced pass ~1.5s after the last change so a
11154
+ * burst of edits triggers a single re-ingest. CPU-only and fast.
11155
+ */
11156
+ scheduleContextFitReingest() {
11157
+ if (this.cfReingestTimer) clearTimeout(this.cfReingestTimer);
11158
+ this.cfReingestTimer = setTimeout(() => {
11159
+ this.cfReingestTimer = null;
11160
+ void this.runContextFitReingest();
11161
+ }, 1500);
11162
+ }
11163
+ async runContextFitReingest() {
11164
+ if (this.cfReingestInFlight) {
11165
+ this.scheduleContextFitReingest();
11166
+ return;
11167
+ }
11168
+ this.cfReingestInFlight = true;
11169
+ try {
11170
+ const { indexVaultWithContextFit: indexVaultWithContextFit2 } = await Promise.resolve().then(() => (init_contextfit(), contextfit_exports));
11171
+ const r = await indexVaultWithContextFit2(this.opts.vault.config, {});
11172
+ if (r.status === "completed") {
11173
+ this.opts.log(`ContextFit KB refreshed (${r.durationMs}ms)`);
11174
+ } else {
11175
+ this.opts.log(`ContextFit KB refresh failed: ${r.error}`);
11176
+ }
11177
+ } catch (err) {
11178
+ this.opts.log(
11179
+ `ContextFit KB refresh error: ${err instanceof Error ? err.message : String(err)}`
11180
+ );
11181
+ } finally {
11182
+ this.cfReingestInFlight = false;
11183
+ }
11184
+ }
10802
11185
  onFsEvent(absolutePath, kind) {
10803
11186
  if (!absolutePath.endsWith(".md")) return;
10804
11187
  const relativePath = this.toRelative(absolutePath);
@@ -10817,10 +11200,12 @@ var init_watcher = __esm({
10817
11200
  }
10818
11201
  async handleFlush(event) {
10819
11202
  const relativePath = this.toRelative(event.path);
11203
+ const isContextFit2 = this.opts.vault.config.backend === "contextfit";
10820
11204
  if (event.kind === "delete") {
10821
11205
  const result2 = removeNote(this.opts.vault, event.path);
10822
11206
  if (result2.removed) {
10823
11207
  this.opts.log(`removed ${relativePath}`);
11208
+ if (isContextFit2) this.scheduleContextFitReingest();
10824
11209
  } else {
10825
11210
  this.opts.log(`delete event for unknown ${relativePath} (skip)`);
10826
11211
  }
@@ -10831,13 +11216,16 @@ var init_watcher = __esm({
10831
11216
  absolutePath: event.path,
10832
11217
  embeddingModel: this.opts.embeddingModel,
10833
11218
  secondaryEmbeddingModel: this.opts.secondaryEmbeddingModel,
10834
- ollama: this.opts.ollama
11219
+ // ADR-008: ContextFit vaults build the SQLite layer without embeddings;
11220
+ // their search KB is refreshed by the debounced re-ingest below.
11221
+ ...isContextFit2 ? { embeddings: "none" } : { ollama: this.opts.ollama }
10835
11222
  });
10836
11223
  switch (result.status) {
10837
11224
  case "indexed":
10838
11225
  this.opts.log(
10839
11226
  `indexed ${relativePath} (${result.isNew ? "new" : "updated"}, ${result.chunksCreated} chunks)`
10840
11227
  );
11228
+ if (isContextFit2) this.scheduleContextFitReingest();
10841
11229
  break;
10842
11230
  case "unchanged":
10843
11231
  break;
@@ -14521,6 +14909,13 @@ async function handleWriteNote(registry, vault, parsed) {
14521
14909
  if (res.observedValue !== void 0) out.observedValue = res.observedValue;
14522
14910
  return out;
14523
14911
  }
14912
+ if (vault.config.backend === "contextfit") {
14913
+ try {
14914
+ const { indexVaultWithContextFit: indexVaultWithContextFit2 } = await Promise.resolve().then(() => (init_contextfit(), contextfit_exports));
14915
+ await indexVaultWithContextFit2(vault.config, {});
14916
+ } catch {
14917
+ }
14918
+ }
14524
14919
  const noteRow = vault.db.notes.getByPath(parsed.path);
14525
14920
  return {
14526
14921
  ok: true,
@@ -14734,7 +15129,12 @@ function handleSearchText(manager, activeVault, query, vaultFilter, topK, exclud
14734
15129
  const fanK = hasExclude ? topK * 3 : topK;
14735
15130
  const sanitized = FtsQueries.sanitize(query);
14736
15131
  const allHits = [];
15132
+ const skippedContextFit = [];
14737
15133
  for (const vault of targets) {
15134
+ if (vault.config.backend === "contextfit") {
15135
+ skippedContextFit.push(vault.config.name);
15136
+ continue;
15137
+ }
14738
15138
  const ftsHits = vault.db.fts.search(sanitized, fanK, true);
14739
15139
  for (const hit of ftsHits) {
14740
15140
  const chunk = vault.db.chunks.getById(hit.chunkId);
@@ -14759,9 +15159,14 @@ function handleSearchText(manager, activeVault, query, vaultFilter, topK, exclud
14759
15159
  hits: allHits.slice(0, topK),
14760
15160
  count: allHits.length
14761
15161
  };
14762
- if (skipped.length > 0) {
14763
- out.note = `Skipped vault(s) currently indexing: ${skipped.join(", ")}.`;
15162
+ const notes = [];
15163
+ if (skipped.length > 0) notes.push(`Skipped vault(s) currently indexing: ${skipped.join(", ")}.`);
15164
+ if (skippedContextFit.length > 0) {
15165
+ notes.push(
15166
+ `search_text is not supported for ContextFit vault(s): ${skippedContextFit.join(", ")} \u2014 use search_hybrid or search_semantic instead.`
15167
+ );
14764
15168
  }
15169
+ if (notes.length > 0) out.note = notes.join(" ");
14765
15170
  return out;
14766
15171
  }
14767
15172
  async function handleSearchHybrid(manager, ollama, defaultModel, activeVault, query, vaultFilter, topK, rrfK, excludePaths, reranker, recencyWeight = 0, authorityWeight = 0, halfLifeDays = 30, includeSuperseded = false, displayUrlFor2, expandOpts, expandDeps) {
@@ -14774,7 +15179,7 @@ async function handleSearchHybrid(manager, ollama, defaultModel, activeVault, qu
14774
15179
  }
14775
15180
  const hasExclude = excludePaths !== void 0 && excludePaths.length > 0;
14776
15181
  const innerTopK = hasExclude ? topK * 3 : topK;
14777
- const hits = await hybridSearch({
15182
+ const hits = await searchVaults({
14778
15183
  query,
14779
15184
  embeddingModel: defaultModel,
14780
15185
  ollama,
@@ -14812,7 +15217,7 @@ async function handleSearchCompat(manager, registry, ollama, defaultModel, activ
14812
15217
  note: skipped.length > 0 ? `All eligible vaults are indexing; skipped: ${skipped.join(", ")}.` : "No vaults configured."
14813
15218
  };
14814
15219
  }
14815
- const hits = await hybridSearch({
15220
+ const hits = await searchVaults({
14816
15221
  query,
14817
15222
  embeddingModel: defaultModel,
14818
15223
  ollama,
@@ -15729,7 +16134,7 @@ __export(server_exports, {
15729
16134
  });
15730
16135
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
15731
16136
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15732
- import { homedir as homedir5 } from "os";
16137
+ import { homedir as homedir6 } from "os";
15733
16138
  import { join as joinPath } from "path";
15734
16139
  async function discoverMemorySinks(configured, vaults) {
15735
16140
  if (configured.length > 0) {
@@ -15796,19 +16201,20 @@ async function serve(options = {}) {
15796
16201
  const activeVault = process.env.VAULT_MEMORY_ACTIVE_VAULT?.trim() || void 0;
15797
16202
  const rerankerBackend = config.server.reranker_backend ?? (config.server.reranker_model ? "onnx" : void 0);
15798
16203
  const reranker = config.server.reranker_model ? rerankerBackend === "ollama" ? new OllamaReranker({ ollama, model: config.server.reranker_model }) : new OnnxReranker({
15799
- modelDir: config.server.reranker_model_dir ?? joinPath(homedir5(), ".vault-memory", "models", "bge-reranker-v2-m3")
16204
+ modelDir: config.server.reranker_model_dir ?? joinPath(homedir6(), ".vault-memory", "models", "bge-reranker-v2-m3")
15800
16205
  }) : void 0;
15801
16206
  const watchers = /* @__PURE__ */ new Map();
15802
16207
  const briefDaemons = /* @__PURE__ */ new Map();
15803
16208
  const startCatchupAndWatchers = async () => {
15804
16209
  for (const vault of manager.list()) {
15805
- if (!vault.config.embedding_model && !vault.db.models.getActive()) continue;
16210
+ const isContextFit2 = vault.config.backend === "contextfit";
16211
+ if (!isContextFit2 && !vault.config.embedding_model && !vault.db.models.getActive()) continue;
15806
16212
  const modelName = vault.config.embedding_model ?? defaultModel;
15807
16213
  try {
15808
16214
  const result = await catchupVault({
15809
16215
  vault,
15810
16216
  embeddingModel: modelName,
15811
- ollama,
16217
+ ...isContextFit2 ? {} : { ollama },
15812
16218
  log: (m) => process.stderr.write(`[catchup:${vault.config.name}] ${m}
15813
16219
  `)
15814
16220
  });
@@ -16891,6 +17297,36 @@ async function runIndex(rest) {
16891
17297
  });
16892
17298
  const targets = vaultName ? [manager.require(vaultName)] : manager.list();
16893
17299
  for (const vault of targets) {
17300
+ if (vault.config.backend === "contextfit") {
17301
+ const { indexVaultWithContextFit: indexVaultWithContextFit2 } = await Promise.resolve().then(() => (init_contextfit(), contextfit_exports));
17302
+ console.error(
17303
+ `
17304
+ \u2192 Indexing "${vault.config.name}" with ContextFit (CPU-only, no embeddings)`
17305
+ );
17306
+ const sqlite = await indexVault2(vault, {
17307
+ mode,
17308
+ embeddingModel: "contextfit",
17309
+ embeddings: "none",
17310
+ onProgress: (msg) => console.error(` ${msg}`)
17311
+ });
17312
+ if (sqlite.status !== "completed") {
17313
+ console.error(`\u2717 ${vault.config.name}: SQLite layer failed \u2014 ${sqlite.error}`);
17314
+ process.exitCode = 1;
17315
+ continue;
17316
+ }
17317
+ const cfResult = await indexVaultWithContextFit2(vault.config, {
17318
+ onProgress: (msg) => console.error(` ${msg}`)
17319
+ });
17320
+ if (cfResult.status === "completed") {
17321
+ console.error(
17322
+ `\u2713 ${vault.config.name}: ${sqlite.notesIndexed} notes (SQLite) + ContextFit KB \xB7 ${sqlite.durationMs + cfResult.durationMs}ms`
17323
+ );
17324
+ } else {
17325
+ console.error(`\u2717 ${vault.config.name}: ContextFit KB failed \u2014 ${cfResult.error}`);
17326
+ process.exitCode = 1;
17327
+ }
17328
+ continue;
17329
+ }
16894
17330
  const model = vault.config.embedding_model ?? config.server.default_embedding_model ?? "qwen3-embedding";
16895
17331
  console.error(`
16896
17332
  \u2192 Indexing "${vault.config.name}" (${mode}) with ${model}`);
@@ -16918,6 +17354,8 @@ async function runAddVault(rest) {
16918
17354
  let name;
16919
17355
  let writeEnabled = false;
16920
17356
  let skipIndex = false;
17357
+ let backend;
17358
+ const USAGE = "Usage: vault-memory add-vault <path> [--name <name>] [--write] [--backend ollama|contextfit] [--no-index]";
16921
17359
  for (let i = 0; i < rest.length; i++) {
16922
17360
  const arg = rest[i];
16923
17361
  if (arg === "--name") {
@@ -16925,24 +17363,37 @@ async function runAddVault(rest) {
16925
17363
  i++;
16926
17364
  } else if (arg === "--write" || arg === "--write-enabled") {
16927
17365
  writeEnabled = true;
17366
+ } else if (arg === "--backend") {
17367
+ const v = rest[i + 1];
17368
+ i++;
17369
+ if (v !== "ollama" && v !== "contextfit") {
17370
+ console.error(`--backend must be "ollama" or "contextfit" (got: ${v ?? "<missing>"})`);
17371
+ process.exit(2);
17372
+ }
17373
+ backend = v;
16928
17374
  } else if (arg === "--no-index") {
16929
17375
  skipIndex = true;
16930
17376
  } else if (arg === "--help" || arg === "-h") {
16931
- console.error(`Usage: vault-memory add-vault <path> [--name <name>] [--write] [--no-index]
17377
+ console.error(`${USAGE}
16932
17378
 
16933
17379
  Registers a vault in ~/.vault-memory/config.toml, writes a .mcp.json
16934
- into the vault root, and runs an initial index. Idempotent.`);
17380
+ into the vault root, and runs an initial index. Idempotent.
17381
+
17382
+ --backend contextfit Use the CPU-only, token-native ContextFit engine
17383
+ (no Ollama / embeddings / GPU). Requires the
17384
+ \`contextfit\` CLI (pipx install contextfit). Ideal for
17385
+ resource-limited / non-GPU hosts (e.g. a Synology NAS).`);
16935
17386
  return;
16936
17387
  } else if (arg && !arg.startsWith("--") && path7 === null) {
16937
17388
  path7 = arg;
16938
17389
  }
16939
17390
  }
16940
17391
  if (path7 === null) {
16941
- console.error("Usage: vault-memory add-vault <path> [--name <name>] [--write] [--no-index]");
17392
+ console.error(USAGE);
16942
17393
  process.exit(2);
16943
17394
  }
16944
- console.error(`\u2192 Registering vault: ${path7}`);
16945
- const result = await addVault2({ path: path7, name, writeEnabled });
17395
+ console.error(`\u2192 Registering vault: ${path7}${backend ? ` (backend: ${backend})` : ""}`);
17396
+ const result = await addVault2({ path: path7, name, writeEnabled, ...backend ? { backend } : {} });
16946
17397
  for (const step of result.steps) {
16947
17398
  switch (step.kind) {
16948
17399
  case "config-added":