@velvetmonkey/flywheel-memory 2.0.72 → 2.0.74

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 +297 -47
  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";
@@ -1969,8 +1969,11 @@ async function buildEmbeddingsIndex(vaultPath2, onProgress) {
1969
1969
  const embedding = await embedText(content);
1970
1970
  const buf = Buffer.from(embedding.buffer, embedding.byteOffset, embedding.byteLength);
1971
1971
  upsert.run(file.path, buf, hash, activeModelConfig.id, Date.now());
1972
- } catch {
1972
+ } catch (err) {
1973
1973
  progress.skipped++;
1974
+ if (progress.skipped <= 3) {
1975
+ console.error(`[Semantic] Failed to embed ${file.path}: ${err instanceof Error ? err.message : err}`);
1976
+ }
1974
1977
  }
1975
1978
  if (onProgress) onProgress(progress);
1976
1979
  }
@@ -2248,6 +2251,29 @@ function loadEntityEmbeddingsToMemory() {
2248
2251
  } catch {
2249
2252
  }
2250
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
+ }
2251
2277
  function getEntityEmbeddingsCount() {
2252
2278
  if (!db) return 0;
2253
2279
  try {
@@ -3239,8 +3265,8 @@ async function upsertNote(index, vaultPath2, notePath) {
3239
3265
  removeNoteFromIndex(index, notePath);
3240
3266
  }
3241
3267
  const fullPath = path7.join(vaultPath2, notePath);
3242
- const fs32 = await import("fs/promises");
3243
- const stats = await fs32.stat(fullPath);
3268
+ const fs33 = await import("fs/promises");
3269
+ const stats = await fs33.stat(fullPath);
3244
3270
  const vaultFile = {
3245
3271
  path: notePath,
3246
3272
  absolutePath: fullPath,
@@ -3826,11 +3852,11 @@ function getAllFeedbackBoosts(stateDb2, folder, now) {
3826
3852
  if (folder !== void 0) {
3827
3853
  folderStatsMap = /* @__PURE__ */ new Map();
3828
3854
  for (const gs of globalStats) {
3829
- const fs32 = getWeightedFolderStats(stateDb2, gs.entity, folder, now);
3830
- if (fs32.rawTotal >= FEEDBACK_BOOST_MIN_SAMPLES) {
3855
+ const fs33 = getWeightedFolderStats(stateDb2, gs.entity, folder, now);
3856
+ if (fs33.rawTotal >= FEEDBACK_BOOST_MIN_SAMPLES) {
3831
3857
  folderStatsMap.set(gs.entity, {
3832
- weightedAccuracy: fs32.weightedAccuracy,
3833
- rawCount: fs32.rawTotal
3858
+ weightedAccuracy: fs33.weightedAccuracy,
3859
+ rawCount: fs33.rawTotal
3834
3860
  });
3835
3861
  }
3836
3862
  }
@@ -3840,10 +3866,10 @@ function getAllFeedbackBoosts(stateDb2, folder, now) {
3840
3866
  if (stat4.rawTotal < FEEDBACK_BOOST_MIN_SAMPLES) continue;
3841
3867
  let accuracy;
3842
3868
  let sampleCount;
3843
- const fs32 = folderStatsMap?.get(stat4.entity);
3844
- if (fs32 && fs32.rawCount >= FEEDBACK_BOOST_MIN_SAMPLES) {
3845
- accuracy = fs32.weightedAccuracy;
3846
- 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;
3847
3873
  } else {
3848
3874
  accuracy = stat4.weightedAccuracy;
3849
3875
  sampleCount = stat4.rawTotal;
@@ -8292,6 +8318,7 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
8292
8318
  },
8293
8319
  async ({ text, limit: requestedLimit, offset, detail }) => {
8294
8320
  const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
8321
+ requireIndex();
8295
8322
  const index = getIndex();
8296
8323
  const allMatches = findEntityMatches(text, index.entities);
8297
8324
  const matches = allMatches.slice(offset, offset + limit);
@@ -8389,11 +8416,19 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
8389
8416
  });
8390
8417
  const ValidateLinksOutputSchema = {
8391
8418
  scope: z2.string().describe('What was validated (note path or "all")'),
8392
- total_links: z2.coerce.number().describe("Total number of links checked"),
8393
- valid_links: z2.coerce.number().describe("Number of valid links"),
8394
- broken_links: z2.coerce.number().describe("Total number of broken links"),
8419
+ total_links: z2.coerce.number().optional().describe("Total number of links checked"),
8420
+ valid_links: z2.coerce.number().optional().describe("Number of valid links"),
8421
+ broken_links: z2.coerce.number().optional().describe("Total number of broken links"),
8395
8422
  returned_count: z2.coerce.number().describe("Number of broken links returned (may be limited)"),
8396
- broken: z2.array(BrokenLinkSchema).describe("List of broken links")
8423
+ broken: z2.array(BrokenLinkSchema).optional().describe("List of broken links"),
8424
+ total_dead_targets: z2.coerce.number().optional().describe("Number of unique dead link targets (group_by_target mode)"),
8425
+ total_broken_links: z2.coerce.number().optional().describe("Total broken links across all targets (group_by_target mode)"),
8426
+ targets: z2.array(z2.object({
8427
+ target: z2.string(),
8428
+ mention_count: z2.coerce.number(),
8429
+ sources: z2.array(z2.string()),
8430
+ suggestion: z2.string().optional()
8431
+ })).optional().describe("Dead link targets grouped by frequency (group_by_target mode)")
8397
8432
  };
8398
8433
  function findSimilarEntity2(target, entities) {
8399
8434
  const targetLower = target.toLowerCase();
@@ -8425,6 +8460,7 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
8425
8460
  },
8426
8461
  async ({ path: notePath, typos_only, group_by_target, limit: requestedLimit, offset }) => {
8427
8462
  const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
8463
+ requireIndex();
8428
8464
  const index = getIndex();
8429
8465
  const allBroken = [];
8430
8466
  let totalLinks = 0;
@@ -8477,12 +8513,13 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
8477
8513
  targetMap.set(key, {
8478
8514
  count: 1,
8479
8515
  sources: /* @__PURE__ */ new Set([broken2.source]),
8480
- suggestion: broken2.suggestion
8516
+ suggestion: broken2.suggestion,
8517
+ displayTarget: broken2.target
8481
8518
  });
8482
8519
  }
8483
8520
  }
8484
- const targets = Array.from(targetMap.entries()).map(([target, data]) => ({
8485
- target,
8521
+ const targets = Array.from(targetMap.values()).map((data) => ({
8522
+ target: data.displayTarget,
8486
8523
  mention_count: data.count,
8487
8524
  sources: Array.from(data.sources),
8488
8525
  ...data.suggestion ? { suggestion: data.suggestion } : {}
@@ -8530,6 +8567,7 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
8530
8567
  }
8531
8568
  },
8532
8569
  async ({ min_frequency, limit: requestedLimit }) => {
8570
+ requireIndex();
8533
8571
  const index = getIndex();
8534
8572
  const limit = Math.min(requestedLimit ?? 20, 100);
8535
8573
  const minFreq = min_frequency ?? 2;
@@ -8578,6 +8616,7 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
8578
8616
  }
8579
8617
  },
8580
8618
  async ({ min_cooccurrence, limit: requestedLimit }) => {
8619
+ requireIndex();
8581
8620
  const index = getIndex();
8582
8621
  const coocIndex = getCooccurrenceIndex();
8583
8622
  const limit = Math.min(requestedLimit ?? 20, 100);
@@ -11770,6 +11809,10 @@ function isPeriodicNote(notePath) {
11770
11809
  const folder = notePath.split("/")[0]?.toLowerCase() || "";
11771
11810
  return patterns.some((p) => p.test(nameWithoutExt)) || periodicFolders.includes(folder);
11772
11811
  }
11812
+ function isTemplatePath(notePath) {
11813
+ const folder = notePath.split("/")[0]?.toLowerCase() || "";
11814
+ return folder === "templates" || folder === "template";
11815
+ }
11773
11816
  function getExcludedPaths(index, config) {
11774
11817
  const excluded = /* @__PURE__ */ new Set();
11775
11818
  const excludeTags = new Set((config.exclude_analysis_tags ?? []).map((t) => t.toLowerCase()));
@@ -11893,7 +11936,9 @@ function registerGraphAnalysisTools(server2, getIndex, getVaultPath, getStateDb,
11893
11936
  }, null, 2) }]
11894
11937
  };
11895
11938
  }
11896
- const result = getStaleNotes(index, days, min_backlinks).filter((n) => !excludedPaths.has(n.path)).slice(0, limit);
11939
+ const result = getStaleNotes(index, days, min_backlinks).filter(
11940
+ (n) => !excludedPaths.has(n.path) && !isPeriodicNote(n.path) && !isTemplatePath(n.path)
11941
+ ).slice(0, limit);
11897
11942
  return {
11898
11943
  content: [{ type: "text", text: JSON.stringify({
11899
11944
  analysis: "stale",
@@ -17342,6 +17387,142 @@ function registerMemoryTools(server2, getStateDb) {
17342
17387
  // src/tools/read/recall.ts
17343
17388
  import { z as z24 } from "zod";
17344
17389
  import { searchEntities as searchEntitiesDb2 } from "@velvetmonkey/vault-core";
17390
+
17391
+ // src/core/read/mmr.ts
17392
+ function selectByMmr(candidates, limit, lambda = 0.7) {
17393
+ if (candidates.length <= limit) return candidates;
17394
+ if (candidates.length === 0) return [];
17395
+ const maxScore = Math.max(...candidates.map((c) => c.score));
17396
+ if (maxScore === 0) return candidates.slice(0, limit);
17397
+ const normScores = /* @__PURE__ */ new Map();
17398
+ for (const c of candidates) {
17399
+ normScores.set(c.id, c.score / maxScore);
17400
+ }
17401
+ const selected = [];
17402
+ const remaining = new Set(candidates.map((_, i) => i));
17403
+ let bestIdx = 0;
17404
+ let bestScore = -Infinity;
17405
+ for (const idx of remaining) {
17406
+ if (candidates[idx].score > bestScore) {
17407
+ bestScore = candidates[idx].score;
17408
+ bestIdx = idx;
17409
+ }
17410
+ }
17411
+ selected.push(candidates[bestIdx]);
17412
+ remaining.delete(bestIdx);
17413
+ while (selected.length < limit && remaining.size > 0) {
17414
+ let bestMmr = -Infinity;
17415
+ let bestCandidate = -1;
17416
+ for (const idx of remaining) {
17417
+ const candidate = candidates[idx];
17418
+ const relevance = normScores.get(candidate.id) || 0;
17419
+ let maxSim = 0;
17420
+ if (candidate.embedding !== null) {
17421
+ for (const sel of selected) {
17422
+ if (sel.embedding !== null) {
17423
+ const sim = cosineSimilarity(candidate.embedding, sel.embedding);
17424
+ if (sim > maxSim) maxSim = sim;
17425
+ }
17426
+ }
17427
+ }
17428
+ const mmr = lambda * relevance - (1 - lambda) * maxSim;
17429
+ if (mmr > bestMmr) {
17430
+ bestMmr = mmr;
17431
+ bestCandidate = idx;
17432
+ }
17433
+ }
17434
+ if (bestCandidate === -1) break;
17435
+ selected.push(candidates[bestCandidate]);
17436
+ remaining.delete(bestCandidate);
17437
+ }
17438
+ return selected;
17439
+ }
17440
+
17441
+ // src/core/read/snippets.ts
17442
+ import * as fs29 from "fs";
17443
+ function stripFrontmatter(content) {
17444
+ const match = content.match(/^---[\s\S]*?---\n([\s\S]*)$/);
17445
+ return match ? match[1] : content;
17446
+ }
17447
+ function splitIntoParagraphs(content, maxChunkChars) {
17448
+ const MIN_PARAGRAPH_CHARS = 50;
17449
+ const raw = content.split(/\n\n+/).map((p) => p.trim()).filter((p) => p.length > 0);
17450
+ const merged = [];
17451
+ let buffer2 = "";
17452
+ for (const paragraph of raw) {
17453
+ if (buffer2) {
17454
+ buffer2 += "\n\n" + paragraph;
17455
+ if (buffer2.length >= MIN_PARAGRAPH_CHARS) {
17456
+ merged.push(buffer2.slice(0, maxChunkChars));
17457
+ buffer2 = "";
17458
+ }
17459
+ } else if (paragraph.length < MIN_PARAGRAPH_CHARS) {
17460
+ buffer2 = paragraph;
17461
+ } else {
17462
+ merged.push(paragraph.slice(0, maxChunkChars));
17463
+ }
17464
+ }
17465
+ if (buffer2) {
17466
+ merged.push(buffer2.slice(0, maxChunkChars));
17467
+ }
17468
+ return merged;
17469
+ }
17470
+ function scoreByKeywords(chunk, queryTokens, queryStems) {
17471
+ const chunkTokens = new Set(tokenize(chunk.toLowerCase()));
17472
+ const chunkStems = new Set([...chunkTokens].map((t) => stem(t)));
17473
+ let score = 0;
17474
+ for (let i = 0; i < queryTokens.length; i++) {
17475
+ if (chunkTokens.has(queryTokens[i])) {
17476
+ score += 10;
17477
+ } else if (chunkStems.has(queryStems[i])) {
17478
+ score += 5;
17479
+ }
17480
+ }
17481
+ return score;
17482
+ }
17483
+ async function extractBestSnippets(filePath, queryEmbedding, queryTokens, options) {
17484
+ const maxSnippets = options?.maxSnippets ?? 1;
17485
+ const maxChunkChars = options?.maxChunkChars ?? 500;
17486
+ let content;
17487
+ try {
17488
+ content = fs29.readFileSync(filePath, "utf-8");
17489
+ } catch {
17490
+ return [];
17491
+ }
17492
+ const body = stripFrontmatter(content);
17493
+ if (body.length < 50) {
17494
+ return body.length > 0 ? [{ text: body, score: 1 }] : [];
17495
+ }
17496
+ const paragraphs = splitIntoParagraphs(body, maxChunkChars);
17497
+ if (paragraphs.length === 0) return [];
17498
+ const queryStems = queryTokens.map((t) => stem(t));
17499
+ const scored = paragraphs.map((text, idx) => ({
17500
+ text,
17501
+ idx,
17502
+ keywordScore: scoreByKeywords(text, queryTokens, queryStems)
17503
+ }));
17504
+ scored.sort((a, b) => b.keywordScore - a.keywordScore);
17505
+ const topKeyword = scored.slice(0, 5);
17506
+ if (queryEmbedding && hasEmbeddingsIndex()) {
17507
+ try {
17508
+ const reranked = [];
17509
+ for (const chunk of topKeyword) {
17510
+ const chunkEmbedding = await embedTextCached(chunk.text);
17511
+ const sim = cosineSimilarity(queryEmbedding, chunkEmbedding);
17512
+ reranked.push({ text: chunk.text, score: sim });
17513
+ }
17514
+ reranked.sort((a, b) => b.score - a.score);
17515
+ return reranked.slice(0, maxSnippets);
17516
+ } catch {
17517
+ }
17518
+ }
17519
+ return topKeyword.slice(0, maxSnippets).map((c) => ({
17520
+ text: c.text,
17521
+ score: c.keywordScore
17522
+ }));
17523
+ }
17524
+
17525
+ // src/tools/read/recall.ts
17345
17526
  function scoreTextRelevance(query, content) {
17346
17527
  const queryTokens = tokenize(query).map((t) => t.toLowerCase());
17347
17528
  const queryStems = queryTokens.map((t) => stem(t));
@@ -17373,7 +17554,9 @@ async function performRecall(stateDb2, query, options = {}) {
17373
17554
  max_results = 20,
17374
17555
  focus = "all",
17375
17556
  entity,
17376
- max_tokens
17557
+ max_tokens,
17558
+ diversity = 0.7,
17559
+ vaultPath: vaultPath2
17377
17560
  } = options;
17378
17561
  const results = [];
17379
17562
  const recencyIndex2 = loadRecencyFromStateDb();
@@ -17498,11 +17681,60 @@ async function performRecall(stateDb2, query, options = {}) {
17498
17681
  seen.add(key);
17499
17682
  return true;
17500
17683
  });
17501
- const truncated = deduped.slice(0, max_results);
17684
+ let selected;
17685
+ if (hasEmbeddingsIndex() && deduped.length > max_results) {
17686
+ const notePaths = deduped.filter((r) => r.type === "note").map((r) => r.id);
17687
+ const noteEmbeddings = loadNoteEmbeddingsForPaths(notePaths);
17688
+ let queryEmbedding = null;
17689
+ const mmrCandidates = [];
17690
+ for (const r of deduped) {
17691
+ let embedding = null;
17692
+ if (r.type === "entity") {
17693
+ embedding = getEntityEmbedding(r.id);
17694
+ } else if (r.type === "note") {
17695
+ embedding = noteEmbeddings.get(r.id) ?? null;
17696
+ } else if (r.type === "memory") {
17697
+ try {
17698
+ if (!queryEmbedding) queryEmbedding = await embedTextCached(query);
17699
+ embedding = await embedTextCached(r.content);
17700
+ } catch {
17701
+ }
17702
+ }
17703
+ mmrCandidates.push({ id: `${r.type}:${r.id}`, score: r.score, embedding });
17704
+ }
17705
+ const mmrSelected = selectByMmr(mmrCandidates, max_results, diversity);
17706
+ const selectedIds = new Set(mmrSelected.map((m) => m.id));
17707
+ selected = deduped.filter((r) => selectedIds.has(`${r.type}:${r.id}`));
17708
+ const orderMap = new Map(mmrSelected.map((m, i) => [m.id, i]));
17709
+ selected.sort((a, b) => (orderMap.get(`${a.type}:${a.id}`) ?? 0) - (orderMap.get(`${b.type}:${b.id}`) ?? 0));
17710
+ } else {
17711
+ selected = deduped.slice(0, max_results);
17712
+ }
17713
+ if (vaultPath2) {
17714
+ const queryTokens = tokenize(query).map((t) => t.toLowerCase());
17715
+ let queryEmb = null;
17716
+ if (hasEmbeddingsIndex()) {
17717
+ try {
17718
+ queryEmb = await embedTextCached(query);
17719
+ } catch {
17720
+ }
17721
+ }
17722
+ for (const r of selected) {
17723
+ if (r.type !== "note") continue;
17724
+ try {
17725
+ const absPath = vaultPath2 + "/" + r.id;
17726
+ const snippets = await extractBestSnippets(absPath, queryEmb, queryTokens);
17727
+ if (snippets.length > 0 && snippets[0].text.length > 0) {
17728
+ r.content = snippets[0].text;
17729
+ }
17730
+ } catch {
17731
+ }
17732
+ }
17733
+ }
17502
17734
  if (max_tokens) {
17503
17735
  let tokenBudget = max_tokens;
17504
17736
  const budgeted = [];
17505
- for (const r of truncated) {
17737
+ for (const r of selected) {
17506
17738
  const estimatedTokens = Math.ceil(r.content.length / 4);
17507
17739
  if (tokenBudget - estimatedTokens < 0 && budgeted.length > 0) break;
17508
17740
  tokenBudget -= estimatedTokens;
@@ -17510,9 +17742,9 @@ async function performRecall(stateDb2, query, options = {}) {
17510
17742
  }
17511
17743
  return budgeted;
17512
17744
  }
17513
- return truncated;
17745
+ return selected;
17514
17746
  }
17515
- function registerRecallTools(server2, getStateDb) {
17747
+ function registerRecallTools(server2, getStateDb, getVaultPath) {
17516
17748
  server2.tool(
17517
17749
  "recall",
17518
17750
  "Query everything the system knows about a topic. Searches across entities, notes, and memories with graph-boosted ranking.",
@@ -17521,7 +17753,8 @@ function registerRecallTools(server2, getStateDb) {
17521
17753
  max_results: z24.number().min(1).max(100).optional().describe("Max results (default: 20)"),
17522
17754
  focus: z24.enum(["entities", "notes", "memories", "all"]).optional().describe("Limit search to specific type (default: all)"),
17523
17755
  entity: z24.string().optional().describe("Filter memories by entity association"),
17524
- max_tokens: z24.number().optional().describe("Token budget for response (truncates lower-ranked results)")
17756
+ max_tokens: z24.number().optional().describe("Token budget for response (truncates lower-ranked results)"),
17757
+ diversity: z24.number().min(0).max(1).optional().describe("Relevance vs diversity tradeoff (0=max diversity, 1=pure relevance, default: 0.7)")
17525
17758
  },
17526
17759
  async (args) => {
17527
17760
  const stateDb2 = getStateDb();
@@ -17535,7 +17768,9 @@ function registerRecallTools(server2, getStateDb) {
17535
17768
  max_results: args.max_results,
17536
17769
  focus: args.focus,
17537
17770
  entity: args.entity,
17538
- max_tokens: args.max_tokens
17771
+ max_tokens: args.max_tokens,
17772
+ diversity: args.diversity,
17773
+ vaultPath: getVaultPath?.()
17539
17774
  });
17540
17775
  const entities = results.filter((r) => r.type === "entity");
17541
17776
  const notes = results.filter((r) => r.type === "note");
@@ -17971,7 +18206,7 @@ function registerConfigTools(server2, getConfig, setConfig, getStateDb) {
17971
18206
 
17972
18207
  // src/tools/write/enrich.ts
17973
18208
  import { z as z27 } from "zod";
17974
- import * as fs29 from "fs/promises";
18209
+ import * as fs30 from "fs/promises";
17975
18210
  import * as path30 from "path";
17976
18211
  function hasSkipWikilinks(content) {
17977
18212
  if (!content.startsWith("---")) return false;
@@ -17983,7 +18218,7 @@ function hasSkipWikilinks(content) {
17983
18218
  async function collectMarkdownFiles(dirPath, basePath, excludeFolders) {
17984
18219
  const results = [];
17985
18220
  try {
17986
- const entries = await fs29.readdir(dirPath, { withFileTypes: true });
18221
+ const entries = await fs30.readdir(dirPath, { withFileTypes: true });
17987
18222
  for (const entry of entries) {
17988
18223
  if (entry.name.startsWith(".")) continue;
17989
18224
  const fullPath = path30.join(dirPath, entry.name);
@@ -18058,7 +18293,7 @@ function registerInitTools(server2, vaultPath2, getStateDb) {
18058
18293
  const fullPath = path30.join(vaultPath2, relativePath);
18059
18294
  let content;
18060
18295
  try {
18061
- content = await fs29.readFile(fullPath, "utf-8");
18296
+ content = await fs30.readFile(fullPath, "utf-8");
18062
18297
  } catch {
18063
18298
  continue;
18064
18299
  }
@@ -18086,7 +18321,7 @@ function registerInitTools(server2, vaultPath2, getStateDb) {
18086
18321
  });
18087
18322
  if (!dry_run) {
18088
18323
  const fullPath = path30.join(vaultPath2, relativePath);
18089
- await fs29.writeFile(fullPath, result.content, "utf-8");
18324
+ await fs30.writeFile(fullPath, result.content, "utf-8");
18090
18325
  notesModified++;
18091
18326
  if (stateDb2) {
18092
18327
  trackWikilinkApplications(stateDb2, relativePath, entities);
@@ -18445,7 +18680,7 @@ function registerActivityTools(server2, getStateDb, getSessionId2) {
18445
18680
  import { z as z30 } from "zod";
18446
18681
 
18447
18682
  // src/core/read/similarity.ts
18448
- import * as fs30 from "fs";
18683
+ import * as fs31 from "fs";
18449
18684
  import * as path31 from "path";
18450
18685
  var STOP_WORDS = /* @__PURE__ */ new Set([
18451
18686
  "the",
@@ -18586,7 +18821,7 @@ function findSimilarNotes(db4, vaultPath2, index, sourcePath, options = {}) {
18586
18821
  const absPath = path31.join(vaultPath2, sourcePath);
18587
18822
  let content;
18588
18823
  try {
18589
- content = fs30.readFileSync(absPath, "utf-8");
18824
+ content = fs31.readFileSync(absPath, "utf-8");
18590
18825
  } catch {
18591
18826
  return [];
18592
18827
  }
@@ -18664,6 +18899,7 @@ async function findSemanticSimilarNotes(vaultPath2, index, sourcePath, options =
18664
18899
  }
18665
18900
  async function findHybridSimilarNotes(db4, vaultPath2, index, sourcePath, options = {}) {
18666
18901
  const limit = options.limit ?? 10;
18902
+ const diversity = options.diversity ?? 0.7;
18667
18903
  const bm25Results = findSimilarNotes(db4, vaultPath2, index, sourcePath, {
18668
18904
  limit: limit * 2,
18669
18905
  excludeLinked: options.excludeLinked
@@ -18695,7 +18931,19 @@ async function findHybridSimilarNotes(db4, vaultPath2, index, sourcePath, option
18695
18931
  };
18696
18932
  });
18697
18933
  merged.sort((a, b) => b.score - a.score);
18698
- return merged.slice(0, limit);
18934
+ if (merged.length > limit) {
18935
+ const noteEmbeddings = loadNoteEmbeddingsForPaths(merged.map((m) => m.path));
18936
+ const mmrCandidates = merged.map((m) => ({
18937
+ id: m.path,
18938
+ score: m.score,
18939
+ embedding: noteEmbeddings.get(m.path) ?? null
18940
+ }));
18941
+ const mmrSelected = selectByMmr(mmrCandidates, limit, diversity);
18942
+ const selectedPaths = new Set(mmrSelected.map((m) => m.id));
18943
+ const mergedMap = new Map(merged.map((m) => [m.path, m]));
18944
+ return mmrSelected.map((m) => mergedMap.get(m.id)).filter(Boolean);
18945
+ }
18946
+ return merged;
18699
18947
  }
18700
18948
 
18701
18949
  // src/tools/read/similarity.ts
@@ -18708,10 +18956,11 @@ function registerSimilarityTools(server2, getIndex, getVaultPath, getStateDb) {
18708
18956
  inputSchema: {
18709
18957
  path: z30.string().describe('Path to the source note (relative to vault root, e.g. "projects/alpha.md")'),
18710
18958
  limit: z30.number().optional().describe("Maximum number of similar notes to return (default: 10)"),
18711
- exclude_linked: z30.boolean().optional().describe("Exclude notes already linked to/from the source note (default: true)")
18959
+ exclude_linked: z30.boolean().optional().describe("Exclude notes already linked to/from the source note (default: true)"),
18960
+ diversity: z30.number().min(0).max(1).optional().describe("Relevance vs diversity tradeoff (0=max diversity, 1=pure relevance, default: 0.7)")
18712
18961
  }
18713
18962
  },
18714
- async ({ path: path33, limit, exclude_linked }) => {
18963
+ async ({ path: path33, limit, exclude_linked, diversity }) => {
18715
18964
  const index = getIndex();
18716
18965
  const vaultPath2 = getVaultPath();
18717
18966
  const stateDb2 = getStateDb();
@@ -18730,7 +18979,8 @@ function registerSimilarityTools(server2, getIndex, getVaultPath, getStateDb) {
18730
18979
  }
18731
18980
  const opts = {
18732
18981
  limit: limit ?? 10,
18733
- excludeLinked: exclude_linked ?? true
18982
+ excludeLinked: exclude_linked ?? true,
18983
+ diversity: diversity ?? 0.7
18734
18984
  };
18735
18985
  const useHybrid = hasEmbeddingsIndex();
18736
18986
  const method = useHybrid ? "hybrid" : "bm25";
@@ -18976,7 +19226,7 @@ function registerMergeTools2(server2, getStateDb) {
18976
19226
  }
18977
19227
 
18978
19228
  // src/index.ts
18979
- import * as fs31 from "node:fs/promises";
19229
+ import * as fs32 from "node:fs/promises";
18980
19230
  import { createHash as createHash3 } from "node:crypto";
18981
19231
 
18982
19232
  // src/resources/vault.ts
@@ -19085,7 +19335,7 @@ function registerVaultResources(server2, getIndex) {
19085
19335
  // src/index.ts
19086
19336
  var __filename = fileURLToPath(import.meta.url);
19087
19337
  var __dirname = dirname4(__filename);
19088
- var pkg = JSON.parse(readFileSync4(join17(__dirname, "../package.json"), "utf-8"));
19338
+ var pkg = JSON.parse(readFileSync5(join17(__dirname, "../package.json"), "utf-8"));
19089
19339
  var vaultPath = process.env.PROJECT_PATH || process.env.VAULT_PATH || findVaultRoot();
19090
19340
  var resolvedVaultPath;
19091
19341
  try {
@@ -19392,7 +19642,7 @@ registerSimilarityTools(server, () => vaultIndex, () => vaultPath, () => stateDb
19392
19642
  registerSemanticTools(server, () => vaultPath, () => stateDb);
19393
19643
  registerMergeTools2(server, () => stateDb);
19394
19644
  registerMemoryTools(server, () => stateDb);
19395
- registerRecallTools(server, () => stateDb);
19645
+ registerRecallTools(server, () => stateDb, () => vaultPath);
19396
19646
  registerBriefTools(server, () => stateDb);
19397
19647
  registerVaultResources(server, () => vaultIndex ?? null);
19398
19648
  serverLog("server", `Registered ${_registeredCount} tools, skipped ${_skippedCount}`);
@@ -19538,7 +19788,7 @@ async function buildStartupCatchupBatch(vaultPath2, sinceMs) {
19538
19788
  async function scanDir(dir) {
19539
19789
  let entries;
19540
19790
  try {
19541
- entries = await fs31.readdir(dir, { withFileTypes: true });
19791
+ entries = await fs32.readdir(dir, { withFileTypes: true });
19542
19792
  } catch {
19543
19793
  return;
19544
19794
  }
@@ -19549,7 +19799,7 @@ async function buildStartupCatchupBatch(vaultPath2, sinceMs) {
19549
19799
  await scanDir(fullPath);
19550
19800
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
19551
19801
  try {
19552
- const stat4 = await fs31.stat(fullPath);
19802
+ const stat4 = await fs32.stat(fullPath);
19553
19803
  if (stat4.mtimeMs > sinceMs) {
19554
19804
  events.push({
19555
19805
  type: "upsert",
@@ -19752,7 +20002,7 @@ async function runPostIndexWork(index) {
19752
20002
  continue;
19753
20003
  }
19754
20004
  try {
19755
- const content = await fs31.readFile(path32.join(vaultPath, event.path), "utf-8");
20005
+ const content = await fs32.readFile(path32.join(vaultPath, event.path), "utf-8");
19756
20006
  const hash = createHash3("sha256").update(content).digest("hex").slice(0, 16);
19757
20007
  if (lastContentHashes.get(event.path) === hash) {
19758
20008
  serverLog("watcher", `Hash unchanged, skipping: ${event.path}`);
@@ -20190,7 +20440,7 @@ async function runPostIndexWork(index) {
20190
20440
  for (const event of filteredEvents) {
20191
20441
  if (event.type === "delete" || !event.path.endsWith(".md")) continue;
20192
20442
  try {
20193
- const content = await fs31.readFile(path32.join(vaultPath, event.path), "utf-8");
20443
+ const content = await fs32.readFile(path32.join(vaultPath, event.path), "utf-8");
20194
20444
  const zones = getProtectedZones2(content);
20195
20445
  const linked = new Set(
20196
20446
  (forwardLinkResults.find((r) => r.file === event.path)?.resolved ?? []).map((n) => n.toLowerCase())
@@ -20231,7 +20481,7 @@ async function runPostIndexWork(index) {
20231
20481
  for (const event of filteredEvents) {
20232
20482
  if (event.type === "delete" || !event.path.endsWith(".md")) continue;
20233
20483
  try {
20234
- const content = await fs31.readFile(path32.join(vaultPath, event.path), "utf-8");
20484
+ const content = await fs32.readFile(path32.join(vaultPath, event.path), "utf-8");
20235
20485
  const removed = processImplicitFeedback(stateDb, event.path, content);
20236
20486
  for (const entity of removed) feedbackResults.push({ entity, file: event.path });
20237
20487
  } catch {
@@ -20311,7 +20561,7 @@ async function runPostIndexWork(index) {
20311
20561
  for (const event of filteredEvents) {
20312
20562
  if (event.type === "delete" || !event.path.endsWith(".md")) continue;
20313
20563
  try {
20314
- const content = await fs31.readFile(path32.join(vaultPath, event.path), "utf-8");
20564
+ const content = await fs32.readFile(path32.join(vaultPath, event.path), "utf-8");
20315
20565
  const zones = getProtectedZones2(content);
20316
20566
  const linkedSet = new Set(
20317
20567
  (forwardLinkResults.find((r) => r.file === event.path)?.resolved ?? []).concat(forwardLinkResults.find((r) => r.file === event.path)?.dead ?? []).map((n) => n.toLowerCase())
@@ -20344,7 +20594,7 @@ async function runPostIndexWork(index) {
20344
20594
  for (const event of filteredEvents) {
20345
20595
  if (event.type === "delete" || !event.path.endsWith(".md")) continue;
20346
20596
  try {
20347
- const content = await fs31.readFile(path32.join(vaultPath, event.path), "utf-8");
20597
+ const content = await fs32.readFile(path32.join(vaultPath, event.path), "utf-8");
20348
20598
  const result = await suggestRelatedLinks(content, {
20349
20599
  maxSuggestions: 5,
20350
20600
  strictness: "balanced",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.0.72",
3
+ "version": "2.0.74",
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.72",
55
+ "@velvetmonkey/vault-core": "^2.0.74",
56
56
  "better-sqlite3": "^11.0.0",
57
57
  "chokidar": "^4.0.0",
58
58
  "gray-matter": "^4.0.3",