@velvetmonkey/flywheel-memory 2.0.73 → 2.0.75

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 (2) hide show
  1. package/dist/index.js +288 -55
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1573,7 +1573,7 @@ var init_taskHelpers = __esm({
1573
1573
 
1574
1574
  // src/index.ts
1575
1575
  import * as path32 from "path";
1576
- import { readFileSync as readFileSync4, realpathSync } from "fs";
1576
+ import { readFileSync as readFileSync5, realpathSync } from "fs";
1577
1577
  import { fileURLToPath } from "url";
1578
1578
  import { dirname as dirname4, join as join17 } from "path";
1579
1579
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -2251,6 +2251,29 @@ function loadEntityEmbeddingsToMemory() {
2251
2251
  } catch {
2252
2252
  }
2253
2253
  }
2254
+ function loadNoteEmbeddingsForPaths(paths) {
2255
+ const result = /* @__PURE__ */ new Map();
2256
+ if (!db || paths.length === 0) return result;
2257
+ try {
2258
+ const stmt = db.prepare("SELECT path, embedding FROM note_embeddings WHERE path = ?");
2259
+ for (const p of paths) {
2260
+ const row = stmt.get(p);
2261
+ if (row) {
2262
+ const embedding = new Float32Array(
2263
+ row.embedding.buffer,
2264
+ row.embedding.byteOffset,
2265
+ row.embedding.byteLength / Float32Array.BYTES_PER_ELEMENT
2266
+ );
2267
+ result.set(p, embedding);
2268
+ }
2269
+ }
2270
+ } catch {
2271
+ }
2272
+ return result;
2273
+ }
2274
+ function getEntityEmbedding(entityName) {
2275
+ return entityEmbeddingsMap.get(entityName) ?? null;
2276
+ }
2254
2277
  function getEntityEmbeddingsCount() {
2255
2278
  if (!db) return 0;
2256
2279
  try {
@@ -3242,8 +3265,8 @@ async function upsertNote(index, vaultPath2, notePath) {
3242
3265
  removeNoteFromIndex(index, notePath);
3243
3266
  }
3244
3267
  const fullPath = path7.join(vaultPath2, notePath);
3245
- const fs32 = await import("fs/promises");
3246
- const stats = await fs32.stat(fullPath);
3268
+ const fs33 = await import("fs/promises");
3269
+ const stats = await fs33.stat(fullPath);
3247
3270
  const vaultFile = {
3248
3271
  path: notePath,
3249
3272
  absolutePath: fullPath,
@@ -3829,11 +3852,11 @@ function getAllFeedbackBoosts(stateDb2, folder, now) {
3829
3852
  if (folder !== void 0) {
3830
3853
  folderStatsMap = /* @__PURE__ */ new Map();
3831
3854
  for (const gs of globalStats) {
3832
- const fs32 = getWeightedFolderStats(stateDb2, gs.entity, folder, now);
3833
- if (fs32.rawTotal >= FEEDBACK_BOOST_MIN_SAMPLES) {
3855
+ const fs33 = getWeightedFolderStats(stateDb2, gs.entity, folder, now);
3856
+ if (fs33.rawTotal >= FEEDBACK_BOOST_MIN_SAMPLES) {
3834
3857
  folderStatsMap.set(gs.entity, {
3835
- weightedAccuracy: fs32.weightedAccuracy,
3836
- rawCount: fs32.rawTotal
3858
+ weightedAccuracy: fs33.weightedAccuracy,
3859
+ rawCount: fs33.rawTotal
3837
3860
  });
3838
3861
  }
3839
3862
  }
@@ -3843,10 +3866,10 @@ function getAllFeedbackBoosts(stateDb2, folder, now) {
3843
3866
  if (stat4.rawTotal < FEEDBACK_BOOST_MIN_SAMPLES) continue;
3844
3867
  let accuracy;
3845
3868
  let sampleCount;
3846
- const fs32 = folderStatsMap?.get(stat4.entity);
3847
- if (fs32 && fs32.rawCount >= FEEDBACK_BOOST_MIN_SAMPLES) {
3848
- accuracy = fs32.weightedAccuracy;
3849
- sampleCount = fs32.rawCount;
3869
+ const fs33 = folderStatsMap?.get(stat4.entity);
3870
+ if (fs33 && fs33.rawCount >= FEEDBACK_BOOST_MIN_SAMPLES) {
3871
+ accuracy = fs33.weightedAccuracy;
3872
+ sampleCount = fs33.rawCount;
3850
3873
  } else {
3851
3874
  accuracy = stat4.weightedAccuracy;
3852
3875
  sampleCount = stat4.rawTotal;
@@ -8490,12 +8513,13 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
8490
8513
  targetMap.set(key, {
8491
8514
  count: 1,
8492
8515
  sources: /* @__PURE__ */ new Set([broken2.source]),
8493
- suggestion: broken2.suggestion
8516
+ suggestion: broken2.suggestion,
8517
+ displayTarget: broken2.target
8494
8518
  });
8495
8519
  }
8496
8520
  }
8497
- const targets = Array.from(targetMap.entries()).map(([target, data]) => ({
8498
- target,
8521
+ const targets = Array.from(targetMap.values()).map((data) => ({
8522
+ target: data.displayTarget,
8499
8523
  mention_count: data.count,
8500
8524
  sources: Array.from(data.sources),
8501
8525
  ...data.suggestion ? { suggestion: data.suggestion } : {}
@@ -9819,21 +9843,23 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
9819
9843
  if (hasEmbeddingsIndex()) {
9820
9844
  try {
9821
9845
  const semanticResults = await semanticSearch(query, limit);
9822
- const fts5Ranked = fts5Results.map((r) => ({ path: r.path, title: r.title, snippet: r.snippet }));
9823
- const semanticRanked = semanticResults.map((r) => ({ path: r.path, title: r.title }));
9824
- const entityRankedList = entityResults.map((r) => ({ path: r.path, title: r.name }));
9846
+ const normalizePath2 = (p) => p.replace(/\\/g, "/").replace(/\/+/g, "/");
9847
+ const fts5Ranked = fts5Results.map((r) => ({ path: normalizePath2(r.path), title: r.title, snippet: r.snippet }));
9848
+ const semanticRanked = semanticResults.map((r) => ({ path: normalizePath2(r.path), title: r.title }));
9849
+ const entityRankedList = entityResults.map((r) => ({ path: normalizePath2(r.path), title: r.name }));
9850
+ const edgeRankedNorm = edgeRanked.map((r) => ({ path: normalizePath2(r.path), title: r.title }));
9825
9851
  const rrfLists = [fts5Ranked, semanticRanked, entityRankedList];
9826
- if (edgeRanked.length > 0) rrfLists.push(edgeRanked);
9852
+ if (edgeRankedNorm.length > 0) rrfLists.push(edgeRankedNorm);
9827
9853
  const rrfScores = reciprocalRankFusion(...rrfLists);
9828
9854
  const allPaths = /* @__PURE__ */ new Set([
9829
- ...fts5Results.map((r) => r.path),
9830
- ...semanticResults.map((r) => r.path),
9831
- ...entityResults.map((r) => r.path),
9832
- ...edgeRanked.map((r) => r.path)
9855
+ ...fts5Results.map((r) => normalizePath2(r.path)),
9856
+ ...semanticResults.map((r) => normalizePath2(r.path)),
9857
+ ...entityResults.map((r) => normalizePath2(r.path)),
9858
+ ...edgeRanked.map((r) => normalizePath2(r.path))
9833
9859
  ]);
9834
- const fts5Map = new Map(fts5Results.map((r) => [r.path, r]));
9835
- const semanticMap = new Map(semanticResults.map((r) => [r.path, r]));
9836
- const entityMap = new Map(entityResults.map((r) => [r.path, r]));
9860
+ const fts5Map = new Map(fts5Results.map((r) => [normalizePath2(r.path), r]));
9861
+ const semanticMap = new Map(semanticResults.map((r) => [normalizePath2(r.path), r]));
9862
+ const entityMap = new Map(entityResults.map((r) => [normalizePath2(r.path), r]));
9837
9863
  const merged = Array.from(allPaths).map((p) => ({
9838
9864
  path: p,
9839
9865
  title: fts5Map.get(p)?.title || semanticMap.get(p)?.title || entityMap.get(p)?.name || p.replace(/\.md$/, "").split("/").pop() || p,
@@ -9856,10 +9882,12 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
9856
9882
  }
9857
9883
  }
9858
9884
  if (entityResults.length > 0) {
9859
- const fts5Map = new Map(fts5Results.map((r) => [r.path, r]));
9860
- const entityRanked = entityResults.filter((r) => !fts5Map.has(r.path));
9885
+ const normalizePath2 = (p) => p.replace(/\\/g, "/").replace(/\/+/g, "/");
9886
+ const fts5Map = new Map(fts5Results.map((r) => [normalizePath2(r.path), r]));
9887
+ const entityNormMap = new Map(entityResults.map((r) => [normalizePath2(r.path), r]));
9888
+ const entityRanked = entityResults.filter((r) => !fts5Map.has(normalizePath2(r.path)));
9861
9889
  const merged = [
9862
- ...fts5Results.map((r) => ({ path: r.path, title: r.title, snippet: r.snippet, in_entity: fts5Map.has(r.path) && entityResults.some((e) => e.path === r.path) })),
9890
+ ...fts5Results.map((r) => ({ path: r.path, title: r.title, snippet: r.snippet, in_entity: entityNormMap.has(normalizePath2(r.path)) })),
9863
9891
  ...entityRanked.map((r) => ({ path: r.path, title: r.name, snippet: void 0, in_entity: true }))
9864
9892
  ];
9865
9893
  return { content: [{ type: "text", text: JSON.stringify({
@@ -17363,6 +17391,142 @@ function registerMemoryTools(server2, getStateDb) {
17363
17391
  // src/tools/read/recall.ts
17364
17392
  import { z as z24 } from "zod";
17365
17393
  import { searchEntities as searchEntitiesDb2 } from "@velvetmonkey/vault-core";
17394
+
17395
+ // src/core/read/mmr.ts
17396
+ function selectByMmr(candidates, limit, lambda = 0.7) {
17397
+ if (candidates.length <= limit) return candidates;
17398
+ if (candidates.length === 0) return [];
17399
+ const maxScore = Math.max(...candidates.map((c) => c.score));
17400
+ if (maxScore === 0) return candidates.slice(0, limit);
17401
+ const normScores = /* @__PURE__ */ new Map();
17402
+ for (const c of candidates) {
17403
+ normScores.set(c.id, c.score / maxScore);
17404
+ }
17405
+ const selected = [];
17406
+ const remaining = new Set(candidates.map((_, i) => i));
17407
+ let bestIdx = 0;
17408
+ let bestScore = -Infinity;
17409
+ for (const idx of remaining) {
17410
+ if (candidates[idx].score > bestScore) {
17411
+ bestScore = candidates[idx].score;
17412
+ bestIdx = idx;
17413
+ }
17414
+ }
17415
+ selected.push(candidates[bestIdx]);
17416
+ remaining.delete(bestIdx);
17417
+ while (selected.length < limit && remaining.size > 0) {
17418
+ let bestMmr = -Infinity;
17419
+ let bestCandidate = -1;
17420
+ for (const idx of remaining) {
17421
+ const candidate = candidates[idx];
17422
+ const relevance = normScores.get(candidate.id) || 0;
17423
+ let maxSim = 0;
17424
+ if (candidate.embedding !== null) {
17425
+ for (const sel of selected) {
17426
+ if (sel.embedding !== null) {
17427
+ const sim = cosineSimilarity(candidate.embedding, sel.embedding);
17428
+ if (sim > maxSim) maxSim = sim;
17429
+ }
17430
+ }
17431
+ }
17432
+ const mmr = lambda * relevance - (1 - lambda) * maxSim;
17433
+ if (mmr > bestMmr) {
17434
+ bestMmr = mmr;
17435
+ bestCandidate = idx;
17436
+ }
17437
+ }
17438
+ if (bestCandidate === -1) break;
17439
+ selected.push(candidates[bestCandidate]);
17440
+ remaining.delete(bestCandidate);
17441
+ }
17442
+ return selected;
17443
+ }
17444
+
17445
+ // src/core/read/snippets.ts
17446
+ import * as fs29 from "fs";
17447
+ function stripFrontmatter(content) {
17448
+ const match = content.match(/^---[\s\S]*?---\n([\s\S]*)$/);
17449
+ return match ? match[1] : content;
17450
+ }
17451
+ function splitIntoParagraphs(content, maxChunkChars) {
17452
+ const MIN_PARAGRAPH_CHARS = 50;
17453
+ const raw = content.split(/\n\n+/).map((p) => p.trim()).filter((p) => p.length > 0);
17454
+ const merged = [];
17455
+ let buffer2 = "";
17456
+ for (const paragraph of raw) {
17457
+ if (buffer2) {
17458
+ buffer2 += "\n\n" + paragraph;
17459
+ if (buffer2.length >= MIN_PARAGRAPH_CHARS) {
17460
+ merged.push(buffer2.slice(0, maxChunkChars));
17461
+ buffer2 = "";
17462
+ }
17463
+ } else if (paragraph.length < MIN_PARAGRAPH_CHARS) {
17464
+ buffer2 = paragraph;
17465
+ } else {
17466
+ merged.push(paragraph.slice(0, maxChunkChars));
17467
+ }
17468
+ }
17469
+ if (buffer2) {
17470
+ merged.push(buffer2.slice(0, maxChunkChars));
17471
+ }
17472
+ return merged;
17473
+ }
17474
+ function scoreByKeywords(chunk, queryTokens, queryStems) {
17475
+ const chunkTokens = new Set(tokenize(chunk.toLowerCase()));
17476
+ const chunkStems = new Set([...chunkTokens].map((t) => stem(t)));
17477
+ let score = 0;
17478
+ for (let i = 0; i < queryTokens.length; i++) {
17479
+ if (chunkTokens.has(queryTokens[i])) {
17480
+ score += 10;
17481
+ } else if (chunkStems.has(queryStems[i])) {
17482
+ score += 5;
17483
+ }
17484
+ }
17485
+ return score;
17486
+ }
17487
+ async function extractBestSnippets(filePath, queryEmbedding, queryTokens, options) {
17488
+ const maxSnippets = options?.maxSnippets ?? 1;
17489
+ const maxChunkChars = options?.maxChunkChars ?? 500;
17490
+ let content;
17491
+ try {
17492
+ content = fs29.readFileSync(filePath, "utf-8");
17493
+ } catch {
17494
+ return [];
17495
+ }
17496
+ const body = stripFrontmatter(content);
17497
+ if (body.length < 50) {
17498
+ return body.length > 0 ? [{ text: body, score: 1 }] : [];
17499
+ }
17500
+ const paragraphs = splitIntoParagraphs(body, maxChunkChars);
17501
+ if (paragraphs.length === 0) return [];
17502
+ const queryStems = queryTokens.map((t) => stem(t));
17503
+ const scored = paragraphs.map((text, idx) => ({
17504
+ text,
17505
+ idx,
17506
+ keywordScore: scoreByKeywords(text, queryTokens, queryStems)
17507
+ }));
17508
+ scored.sort((a, b) => b.keywordScore - a.keywordScore);
17509
+ const topKeyword = scored.slice(0, 5);
17510
+ if (queryEmbedding && hasEmbeddingsIndex()) {
17511
+ try {
17512
+ const reranked = [];
17513
+ for (const chunk of topKeyword) {
17514
+ const chunkEmbedding = await embedTextCached(chunk.text);
17515
+ const sim = cosineSimilarity(queryEmbedding, chunkEmbedding);
17516
+ reranked.push({ text: chunk.text, score: sim });
17517
+ }
17518
+ reranked.sort((a, b) => b.score - a.score);
17519
+ return reranked.slice(0, maxSnippets);
17520
+ } catch {
17521
+ }
17522
+ }
17523
+ return topKeyword.slice(0, maxSnippets).map((c) => ({
17524
+ text: c.text,
17525
+ score: c.keywordScore
17526
+ }));
17527
+ }
17528
+
17529
+ // src/tools/read/recall.ts
17366
17530
  function scoreTextRelevance(query, content) {
17367
17531
  const queryTokens = tokenize(query).map((t) => t.toLowerCase());
17368
17532
  const queryStems = queryTokens.map((t) => stem(t));
@@ -17394,7 +17558,9 @@ async function performRecall(stateDb2, query, options = {}) {
17394
17558
  max_results = 20,
17395
17559
  focus = "all",
17396
17560
  entity,
17397
- max_tokens
17561
+ max_tokens,
17562
+ diversity = 0.7,
17563
+ vaultPath: vaultPath2
17398
17564
  } = options;
17399
17565
  const results = [];
17400
17566
  const recencyIndex2 = loadRecencyFromStateDb();
@@ -17519,11 +17685,60 @@ async function performRecall(stateDb2, query, options = {}) {
17519
17685
  seen.add(key);
17520
17686
  return true;
17521
17687
  });
17522
- const truncated = deduped.slice(0, max_results);
17688
+ let selected;
17689
+ if (hasEmbeddingsIndex() && deduped.length > max_results) {
17690
+ const notePaths = deduped.filter((r) => r.type === "note").map((r) => r.id);
17691
+ const noteEmbeddings = loadNoteEmbeddingsForPaths(notePaths);
17692
+ let queryEmbedding = null;
17693
+ const mmrCandidates = [];
17694
+ for (const r of deduped) {
17695
+ let embedding = null;
17696
+ if (r.type === "entity") {
17697
+ embedding = getEntityEmbedding(r.id);
17698
+ } else if (r.type === "note") {
17699
+ embedding = noteEmbeddings.get(r.id) ?? null;
17700
+ } else if (r.type === "memory") {
17701
+ try {
17702
+ if (!queryEmbedding) queryEmbedding = await embedTextCached(query);
17703
+ embedding = await embedTextCached(r.content);
17704
+ } catch {
17705
+ }
17706
+ }
17707
+ mmrCandidates.push({ id: `${r.type}:${r.id}`, score: r.score, embedding });
17708
+ }
17709
+ const mmrSelected = selectByMmr(mmrCandidates, max_results, diversity);
17710
+ const selectedIds = new Set(mmrSelected.map((m) => m.id));
17711
+ selected = deduped.filter((r) => selectedIds.has(`${r.type}:${r.id}`));
17712
+ const orderMap = new Map(mmrSelected.map((m, i) => [m.id, i]));
17713
+ selected.sort((a, b) => (orderMap.get(`${a.type}:${a.id}`) ?? 0) - (orderMap.get(`${b.type}:${b.id}`) ?? 0));
17714
+ } else {
17715
+ selected = deduped.slice(0, max_results);
17716
+ }
17717
+ if (vaultPath2) {
17718
+ const queryTokens = tokenize(query).map((t) => t.toLowerCase());
17719
+ let queryEmb = null;
17720
+ if (hasEmbeddingsIndex()) {
17721
+ try {
17722
+ queryEmb = await embedTextCached(query);
17723
+ } catch {
17724
+ }
17725
+ }
17726
+ for (const r of selected) {
17727
+ if (r.type !== "note") continue;
17728
+ try {
17729
+ const absPath = vaultPath2 + "/" + r.id;
17730
+ const snippets = await extractBestSnippets(absPath, queryEmb, queryTokens);
17731
+ if (snippets.length > 0 && snippets[0].text.length > 0) {
17732
+ r.content = snippets[0].text;
17733
+ }
17734
+ } catch {
17735
+ }
17736
+ }
17737
+ }
17523
17738
  if (max_tokens) {
17524
17739
  let tokenBudget = max_tokens;
17525
17740
  const budgeted = [];
17526
- for (const r of truncated) {
17741
+ for (const r of selected) {
17527
17742
  const estimatedTokens = Math.ceil(r.content.length / 4);
17528
17743
  if (tokenBudget - estimatedTokens < 0 && budgeted.length > 0) break;
17529
17744
  tokenBudget -= estimatedTokens;
@@ -17531,9 +17746,9 @@ async function performRecall(stateDb2, query, options = {}) {
17531
17746
  }
17532
17747
  return budgeted;
17533
17748
  }
17534
- return truncated;
17749
+ return selected;
17535
17750
  }
17536
- function registerRecallTools(server2, getStateDb) {
17751
+ function registerRecallTools(server2, getStateDb, getVaultPath) {
17537
17752
  server2.tool(
17538
17753
  "recall",
17539
17754
  "Query everything the system knows about a topic. Searches across entities, notes, and memories with graph-boosted ranking.",
@@ -17542,7 +17757,8 @@ function registerRecallTools(server2, getStateDb) {
17542
17757
  max_results: z24.number().min(1).max(100).optional().describe("Max results (default: 20)"),
17543
17758
  focus: z24.enum(["entities", "notes", "memories", "all"]).optional().describe("Limit search to specific type (default: all)"),
17544
17759
  entity: z24.string().optional().describe("Filter memories by entity association"),
17545
- max_tokens: z24.number().optional().describe("Token budget for response (truncates lower-ranked results)")
17760
+ max_tokens: z24.number().optional().describe("Token budget for response (truncates lower-ranked results)"),
17761
+ diversity: z24.number().min(0).max(1).optional().describe("Relevance vs diversity tradeoff (0=max diversity, 1=pure relevance, default: 0.7)")
17546
17762
  },
17547
17763
  async (args) => {
17548
17764
  const stateDb2 = getStateDb();
@@ -17556,7 +17772,9 @@ function registerRecallTools(server2, getStateDb) {
17556
17772
  max_results: args.max_results,
17557
17773
  focus: args.focus,
17558
17774
  entity: args.entity,
17559
- max_tokens: args.max_tokens
17775
+ max_tokens: args.max_tokens,
17776
+ diversity: args.diversity,
17777
+ vaultPath: getVaultPath?.()
17560
17778
  });
17561
17779
  const entities = results.filter((r) => r.type === "entity");
17562
17780
  const notes = results.filter((r) => r.type === "note");
@@ -17992,7 +18210,7 @@ function registerConfigTools(server2, getConfig, setConfig, getStateDb) {
17992
18210
 
17993
18211
  // src/tools/write/enrich.ts
17994
18212
  import { z as z27 } from "zod";
17995
- import * as fs29 from "fs/promises";
18213
+ import * as fs30 from "fs/promises";
17996
18214
  import * as path30 from "path";
17997
18215
  function hasSkipWikilinks(content) {
17998
18216
  if (!content.startsWith("---")) return false;
@@ -18004,7 +18222,7 @@ function hasSkipWikilinks(content) {
18004
18222
  async function collectMarkdownFiles(dirPath, basePath, excludeFolders) {
18005
18223
  const results = [];
18006
18224
  try {
18007
- const entries = await fs29.readdir(dirPath, { withFileTypes: true });
18225
+ const entries = await fs30.readdir(dirPath, { withFileTypes: true });
18008
18226
  for (const entry of entries) {
18009
18227
  if (entry.name.startsWith(".")) continue;
18010
18228
  const fullPath = path30.join(dirPath, entry.name);
@@ -18079,7 +18297,7 @@ function registerInitTools(server2, vaultPath2, getStateDb) {
18079
18297
  const fullPath = path30.join(vaultPath2, relativePath);
18080
18298
  let content;
18081
18299
  try {
18082
- content = await fs29.readFile(fullPath, "utf-8");
18300
+ content = await fs30.readFile(fullPath, "utf-8");
18083
18301
  } catch {
18084
18302
  continue;
18085
18303
  }
@@ -18107,7 +18325,7 @@ function registerInitTools(server2, vaultPath2, getStateDb) {
18107
18325
  });
18108
18326
  if (!dry_run) {
18109
18327
  const fullPath = path30.join(vaultPath2, relativePath);
18110
- await fs29.writeFile(fullPath, result.content, "utf-8");
18328
+ await fs30.writeFile(fullPath, result.content, "utf-8");
18111
18329
  notesModified++;
18112
18330
  if (stateDb2) {
18113
18331
  trackWikilinkApplications(stateDb2, relativePath, entities);
@@ -18466,7 +18684,7 @@ function registerActivityTools(server2, getStateDb, getSessionId2) {
18466
18684
  import { z as z30 } from "zod";
18467
18685
 
18468
18686
  // src/core/read/similarity.ts
18469
- import * as fs30 from "fs";
18687
+ import * as fs31 from "fs";
18470
18688
  import * as path31 from "path";
18471
18689
  var STOP_WORDS = /* @__PURE__ */ new Set([
18472
18690
  "the",
@@ -18607,7 +18825,7 @@ function findSimilarNotes(db4, vaultPath2, index, sourcePath, options = {}) {
18607
18825
  const absPath = path31.join(vaultPath2, sourcePath);
18608
18826
  let content;
18609
18827
  try {
18610
- content = fs30.readFileSync(absPath, "utf-8");
18828
+ content = fs31.readFileSync(absPath, "utf-8");
18611
18829
  } catch {
18612
18830
  return [];
18613
18831
  }
@@ -18685,6 +18903,7 @@ async function findSemanticSimilarNotes(vaultPath2, index, sourcePath, options =
18685
18903
  }
18686
18904
  async function findHybridSimilarNotes(db4, vaultPath2, index, sourcePath, options = {}) {
18687
18905
  const limit = options.limit ?? 10;
18906
+ const diversity = options.diversity ?? 0.7;
18688
18907
  const bm25Results = findSimilarNotes(db4, vaultPath2, index, sourcePath, {
18689
18908
  limit: limit * 2,
18690
18909
  excludeLinked: options.excludeLinked
@@ -18716,7 +18935,19 @@ async function findHybridSimilarNotes(db4, vaultPath2, index, sourcePath, option
18716
18935
  };
18717
18936
  });
18718
18937
  merged.sort((a, b) => b.score - a.score);
18719
- return merged.slice(0, limit);
18938
+ if (merged.length > limit) {
18939
+ const noteEmbeddings = loadNoteEmbeddingsForPaths(merged.map((m) => m.path));
18940
+ const mmrCandidates = merged.map((m) => ({
18941
+ id: m.path,
18942
+ score: m.score,
18943
+ embedding: noteEmbeddings.get(m.path) ?? null
18944
+ }));
18945
+ const mmrSelected = selectByMmr(mmrCandidates, limit, diversity);
18946
+ const selectedPaths = new Set(mmrSelected.map((m) => m.id));
18947
+ const mergedMap = new Map(merged.map((m) => [m.path, m]));
18948
+ return mmrSelected.map((m) => mergedMap.get(m.id)).filter(Boolean);
18949
+ }
18950
+ return merged;
18720
18951
  }
18721
18952
 
18722
18953
  // src/tools/read/similarity.ts
@@ -18729,10 +18960,11 @@ function registerSimilarityTools(server2, getIndex, getVaultPath, getStateDb) {
18729
18960
  inputSchema: {
18730
18961
  path: z30.string().describe('Path to the source note (relative to vault root, e.g. "projects/alpha.md")'),
18731
18962
  limit: z30.number().optional().describe("Maximum number of similar notes to return (default: 10)"),
18732
- exclude_linked: z30.boolean().optional().describe("Exclude notes already linked to/from the source note (default: true)")
18963
+ exclude_linked: z30.boolean().optional().describe("Exclude notes already linked to/from the source note (default: true)"),
18964
+ diversity: z30.number().min(0).max(1).optional().describe("Relevance vs diversity tradeoff (0=max diversity, 1=pure relevance, default: 0.7)")
18733
18965
  }
18734
18966
  },
18735
- async ({ path: path33, limit, exclude_linked }) => {
18967
+ async ({ path: path33, limit, exclude_linked, diversity }) => {
18736
18968
  const index = getIndex();
18737
18969
  const vaultPath2 = getVaultPath();
18738
18970
  const stateDb2 = getStateDb();
@@ -18751,7 +18983,8 @@ function registerSimilarityTools(server2, getIndex, getVaultPath, getStateDb) {
18751
18983
  }
18752
18984
  const opts = {
18753
18985
  limit: limit ?? 10,
18754
- excludeLinked: exclude_linked ?? true
18986
+ excludeLinked: exclude_linked ?? true,
18987
+ diversity: diversity ?? 0.7
18755
18988
  };
18756
18989
  const useHybrid = hasEmbeddingsIndex();
18757
18990
  const method = useHybrid ? "hybrid" : "bm25";
@@ -18997,7 +19230,7 @@ function registerMergeTools2(server2, getStateDb) {
18997
19230
  }
18998
19231
 
18999
19232
  // src/index.ts
19000
- import * as fs31 from "node:fs/promises";
19233
+ import * as fs32 from "node:fs/promises";
19001
19234
  import { createHash as createHash3 } from "node:crypto";
19002
19235
 
19003
19236
  // src/resources/vault.ts
@@ -19106,7 +19339,7 @@ function registerVaultResources(server2, getIndex) {
19106
19339
  // src/index.ts
19107
19340
  var __filename = fileURLToPath(import.meta.url);
19108
19341
  var __dirname = dirname4(__filename);
19109
- var pkg = JSON.parse(readFileSync4(join17(__dirname, "../package.json"), "utf-8"));
19342
+ var pkg = JSON.parse(readFileSync5(join17(__dirname, "../package.json"), "utf-8"));
19110
19343
  var vaultPath = process.env.PROJECT_PATH || process.env.VAULT_PATH || findVaultRoot();
19111
19344
  var resolvedVaultPath;
19112
19345
  try {
@@ -19413,7 +19646,7 @@ registerSimilarityTools(server, () => vaultIndex, () => vaultPath, () => stateDb
19413
19646
  registerSemanticTools(server, () => vaultPath, () => stateDb);
19414
19647
  registerMergeTools2(server, () => stateDb);
19415
19648
  registerMemoryTools(server, () => stateDb);
19416
- registerRecallTools(server, () => stateDb);
19649
+ registerRecallTools(server, () => stateDb, () => vaultPath);
19417
19650
  registerBriefTools(server, () => stateDb);
19418
19651
  registerVaultResources(server, () => vaultIndex ?? null);
19419
19652
  serverLog("server", `Registered ${_registeredCount} tools, skipped ${_skippedCount}`);
@@ -19559,7 +19792,7 @@ async function buildStartupCatchupBatch(vaultPath2, sinceMs) {
19559
19792
  async function scanDir(dir) {
19560
19793
  let entries;
19561
19794
  try {
19562
- entries = await fs31.readdir(dir, { withFileTypes: true });
19795
+ entries = await fs32.readdir(dir, { withFileTypes: true });
19563
19796
  } catch {
19564
19797
  return;
19565
19798
  }
@@ -19570,7 +19803,7 @@ async function buildStartupCatchupBatch(vaultPath2, sinceMs) {
19570
19803
  await scanDir(fullPath);
19571
19804
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
19572
19805
  try {
19573
- const stat4 = await fs31.stat(fullPath);
19806
+ const stat4 = await fs32.stat(fullPath);
19574
19807
  if (stat4.mtimeMs > sinceMs) {
19575
19808
  events.push({
19576
19809
  type: "upsert",
@@ -19773,7 +20006,7 @@ async function runPostIndexWork(index) {
19773
20006
  continue;
19774
20007
  }
19775
20008
  try {
19776
- const content = await fs31.readFile(path32.join(vaultPath, event.path), "utf-8");
20009
+ const content = await fs32.readFile(path32.join(vaultPath, event.path), "utf-8");
19777
20010
  const hash = createHash3("sha256").update(content).digest("hex").slice(0, 16);
19778
20011
  if (lastContentHashes.get(event.path) === hash) {
19779
20012
  serverLog("watcher", `Hash unchanged, skipping: ${event.path}`);
@@ -20211,7 +20444,7 @@ async function runPostIndexWork(index) {
20211
20444
  for (const event of filteredEvents) {
20212
20445
  if (event.type === "delete" || !event.path.endsWith(".md")) continue;
20213
20446
  try {
20214
- const content = await fs31.readFile(path32.join(vaultPath, event.path), "utf-8");
20447
+ const content = await fs32.readFile(path32.join(vaultPath, event.path), "utf-8");
20215
20448
  const zones = getProtectedZones2(content);
20216
20449
  const linked = new Set(
20217
20450
  (forwardLinkResults.find((r) => r.file === event.path)?.resolved ?? []).map((n) => n.toLowerCase())
@@ -20252,7 +20485,7 @@ async function runPostIndexWork(index) {
20252
20485
  for (const event of filteredEvents) {
20253
20486
  if (event.type === "delete" || !event.path.endsWith(".md")) continue;
20254
20487
  try {
20255
- const content = await fs31.readFile(path32.join(vaultPath, event.path), "utf-8");
20488
+ const content = await fs32.readFile(path32.join(vaultPath, event.path), "utf-8");
20256
20489
  const removed = processImplicitFeedback(stateDb, event.path, content);
20257
20490
  for (const entity of removed) feedbackResults.push({ entity, file: event.path });
20258
20491
  } catch {
@@ -20332,7 +20565,7 @@ async function runPostIndexWork(index) {
20332
20565
  for (const event of filteredEvents) {
20333
20566
  if (event.type === "delete" || !event.path.endsWith(".md")) continue;
20334
20567
  try {
20335
- const content = await fs31.readFile(path32.join(vaultPath, event.path), "utf-8");
20568
+ const content = await fs32.readFile(path32.join(vaultPath, event.path), "utf-8");
20336
20569
  const zones = getProtectedZones2(content);
20337
20570
  const linkedSet = new Set(
20338
20571
  (forwardLinkResults.find((r) => r.file === event.path)?.resolved ?? []).concat(forwardLinkResults.find((r) => r.file === event.path)?.dead ?? []).map((n) => n.toLowerCase())
@@ -20365,7 +20598,7 @@ async function runPostIndexWork(index) {
20365
20598
  for (const event of filteredEvents) {
20366
20599
  if (event.type === "delete" || !event.path.endsWith(".md")) continue;
20367
20600
  try {
20368
- const content = await fs31.readFile(path32.join(vaultPath, event.path), "utf-8");
20601
+ const content = await fs32.readFile(path32.join(vaultPath, event.path), "utf-8");
20369
20602
  const result = await suggestRelatedLinks(content, {
20370
20603
  maxSuggestions: 5,
20371
20604
  strictness: "balanced",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.0.73",
3
+ "version": "2.0.75",
4
4
  "description": "MCP server that gives Claude full read/write access to your Obsidian vault. Select from 51 tools for search, backlinks, graph queries, mutations, agent memory, and hybrid semantic search.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@modelcontextprotocol/sdk": "^1.25.1",
55
- "@velvetmonkey/vault-core": "^2.0.73",
55
+ "@velvetmonkey/vault-core": "^2.0.75",
56
56
  "better-sqlite3": "^11.0.0",
57
57
  "chokidar": "^4.0.0",
58
58
  "gray-matter": "^4.0.3",