@velvetmonkey/flywheel-memory 2.0.121 → 2.0.123

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 +481 -93
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -7571,19 +7571,68 @@ function createVaultWatcher(options) {
7571
7571
  }
7572
7572
 
7573
7573
  // src/core/shared/hubExport.ts
7574
+ var EIGEN_ITERATIONS = 50;
7574
7575
  function computeHubScores(index) {
7575
- const hubScores = /* @__PURE__ */ new Map();
7576
+ const nodes = [];
7577
+ const nodeIdx = /* @__PURE__ */ new Map();
7578
+ const adj = [];
7576
7579
  for (const note of index.notes.values()) {
7577
- const backlinks = getBacklinksForNote(index, note.path);
7578
- const backlinkCount = backlinks.length;
7579
- const normalizedPath = normalizeTarget(note.path);
7580
- hubScores.set(normalizedPath, backlinkCount);
7581
- const title = note.title.toLowerCase();
7582
- if (!hubScores.has(title) || backlinkCount > hubScores.get(title)) {
7583
- hubScores.set(title, backlinkCount);
7580
+ const key = normalizeTarget(note.path);
7581
+ if (!nodeIdx.has(key)) {
7582
+ nodeIdx.set(key, nodes.length);
7583
+ nodes.push(key);
7584
+ adj.push([]);
7585
+ }
7586
+ const titleKey = note.title.toLowerCase();
7587
+ if (!nodeIdx.has(titleKey)) {
7588
+ nodeIdx.set(titleKey, nodeIdx.get(key));
7589
+ }
7590
+ }
7591
+ const N = nodes.length;
7592
+ if (N === 0) return /* @__PURE__ */ new Map();
7593
+ for (const note of index.notes.values()) {
7594
+ const fromIdx = nodeIdx.get(normalizeTarget(note.path));
7595
+ if (fromIdx === void 0) continue;
7596
+ for (const link of note.outlinks) {
7597
+ const target = normalizeTarget(link.target);
7598
+ let toIdx = nodeIdx.get(target);
7599
+ if (toIdx === void 0) {
7600
+ toIdx = nodeIdx.get(link.target.toLowerCase());
7601
+ }
7602
+ if (toIdx !== void 0 && toIdx !== fromIdx) {
7603
+ adj[fromIdx].push(toIdx);
7604
+ adj[toIdx].push(fromIdx);
7605
+ }
7584
7606
  }
7585
7607
  }
7586
- return hubScores;
7608
+ let scores = new Float64Array(N).fill(1 / N);
7609
+ for (let iter = 0; iter < EIGEN_ITERATIONS; iter++) {
7610
+ const next = new Float64Array(N);
7611
+ for (let i = 0; i < N; i++) {
7612
+ for (const j of adj[i]) {
7613
+ next[i] += scores[j];
7614
+ }
7615
+ }
7616
+ let norm = 0;
7617
+ for (let i = 0; i < N; i++) norm += next[i] * next[i];
7618
+ norm = Math.sqrt(norm);
7619
+ if (norm > 0) {
7620
+ for (let i = 0; i < N; i++) next[i] /= norm;
7621
+ }
7622
+ scores = next;
7623
+ }
7624
+ let maxScore = 0;
7625
+ for (let i = 0; i < N; i++) {
7626
+ if (scores[i] > maxScore) maxScore = scores[i];
7627
+ }
7628
+ const result = /* @__PURE__ */ new Map();
7629
+ for (let i = 0; i < N; i++) {
7630
+ const scaled = maxScore > 0 ? Math.round(scores[i] / maxScore * 100) : 0;
7631
+ if (scaled > 0) {
7632
+ result.set(nodes[i], scaled);
7633
+ }
7634
+ }
7635
+ return result;
7587
7636
  }
7588
7637
  function updateHubScoresInDb(stateDb2, hubScores) {
7589
7638
  const updateStmt = stateDb2.db.prepare(`
@@ -8510,6 +8559,162 @@ function getConnectionStrength(index, noteAPath, noteBPath) {
8510
8559
  }
8511
8560
  return { score, factors };
8512
8561
  }
8562
+ function computeCentralityMetrics(index, limit = 20) {
8563
+ const paths = Array.from(index.notes.keys());
8564
+ const N = paths.length;
8565
+ if (N === 0) return [];
8566
+ const pathIdx = /* @__PURE__ */ new Map();
8567
+ paths.forEach((p, i) => pathIdx.set(p, i));
8568
+ for (const note of index.notes.values()) {
8569
+ const normalized = note.title.toLowerCase();
8570
+ if (!pathIdx.has(normalized)) {
8571
+ pathIdx.set(normalized, pathIdx.get(note.path));
8572
+ }
8573
+ }
8574
+ const outAdj = Array.from({ length: N }, () => []);
8575
+ const inAdj = Array.from({ length: N }, () => []);
8576
+ for (const note of index.notes.values()) {
8577
+ const fromIdx = pathIdx.get(note.path);
8578
+ if (fromIdx === void 0) continue;
8579
+ for (const link of note.outlinks) {
8580
+ let toIdx = pathIdx.get(link.target);
8581
+ if (toIdx === void 0) toIdx = pathIdx.get(link.target.toLowerCase());
8582
+ if (toIdx !== void 0 && toIdx !== fromIdx) {
8583
+ outAdj[fromIdx].push(toIdx);
8584
+ inAdj[toIdx].push(fromIdx);
8585
+ }
8586
+ }
8587
+ }
8588
+ const degree = new Float64Array(N);
8589
+ for (let i = 0; i < N; i++) {
8590
+ degree[i] = outAdj[i].length + inAdj[i].length;
8591
+ }
8592
+ const betweenness = new Float64Array(N);
8593
+ const closenessSum = new Float64Array(N);
8594
+ const reachable = new Int32Array(N);
8595
+ const undirAdj = Array.from({ length: N }, () => []);
8596
+ for (let i = 0; i < N; i++) {
8597
+ for (const j of outAdj[i]) {
8598
+ undirAdj[i].push(j);
8599
+ undirAdj[j].push(i);
8600
+ }
8601
+ }
8602
+ for (let s = 0; s < N; s++) {
8603
+ const stack = [];
8604
+ const pred = Array.from({ length: N }, () => []);
8605
+ const sigma = new Float64Array(N);
8606
+ const dist = new Int32Array(N).fill(-1);
8607
+ sigma[s] = 1;
8608
+ dist[s] = 0;
8609
+ const queue = [s];
8610
+ let head = 0;
8611
+ while (head < queue.length) {
8612
+ const v = queue[head++];
8613
+ stack.push(v);
8614
+ for (const w of undirAdj[v]) {
8615
+ if (dist[w] < 0) {
8616
+ dist[w] = dist[v] + 1;
8617
+ queue.push(w);
8618
+ }
8619
+ if (dist[w] === dist[v] + 1) {
8620
+ sigma[w] += sigma[v];
8621
+ pred[w].push(v);
8622
+ }
8623
+ }
8624
+ }
8625
+ for (let t = 0; t < N; t++) {
8626
+ if (dist[t] > 0) {
8627
+ closenessSum[s] += dist[t];
8628
+ reachable[s]++;
8629
+ }
8630
+ }
8631
+ const delta = new Float64Array(N);
8632
+ while (stack.length > 0) {
8633
+ const w = stack.pop();
8634
+ for (const v of pred[w]) {
8635
+ delta[v] += sigma[v] / sigma[w] * (1 + delta[w]);
8636
+ }
8637
+ if (w !== s) {
8638
+ betweenness[w] += delta[w];
8639
+ }
8640
+ }
8641
+ }
8642
+ const normFactor = N > 2 ? (N - 1) * (N - 2) : 1;
8643
+ const results = [];
8644
+ for (let i = 0; i < N; i++) {
8645
+ const note = index.notes.get(paths[i]);
8646
+ results.push({
8647
+ path: paths[i],
8648
+ title: note?.title || paths[i],
8649
+ degree: degree[i],
8650
+ betweenness: Math.round(betweenness[i] / normFactor * 1e4) / 1e4,
8651
+ closeness: reachable[i] > 0 ? Math.round(reachable[i] / closenessSum[i] * 1e4) / 1e4 : 0
8652
+ });
8653
+ }
8654
+ results.sort((a, b) => b.betweenness - a.betweenness);
8655
+ return results.slice(0, limit);
8656
+ }
8657
+ function detectCycles(index, maxLength = 10, limit = 20) {
8658
+ const paths = Array.from(index.notes.keys());
8659
+ const N = paths.length;
8660
+ if (N === 0) return [];
8661
+ const pathIdx = /* @__PURE__ */ new Map();
8662
+ paths.forEach((p, i) => pathIdx.set(p, i));
8663
+ for (const note of index.notes.values()) {
8664
+ const normalized = note.title.toLowerCase();
8665
+ if (!pathIdx.has(normalized)) {
8666
+ pathIdx.set(normalized, pathIdx.get(note.path));
8667
+ }
8668
+ }
8669
+ const adj = Array.from({ length: N }, () => []);
8670
+ for (const note of index.notes.values()) {
8671
+ const fromIdx = pathIdx.get(note.path);
8672
+ if (fromIdx === void 0) continue;
8673
+ for (const link of note.outlinks) {
8674
+ let toIdx = pathIdx.get(link.target);
8675
+ if (toIdx === void 0) toIdx = pathIdx.get(link.target.toLowerCase());
8676
+ if (toIdx !== void 0 && toIdx !== fromIdx) {
8677
+ adj[fromIdx].push(toIdx);
8678
+ }
8679
+ }
8680
+ }
8681
+ const WHITE = 0, GRAY = 1, BLACK = 2;
8682
+ const color = new Uint8Array(N);
8683
+ const cycles = [];
8684
+ const stack = [];
8685
+ function dfs(u) {
8686
+ if (cycles.length >= limit) return;
8687
+ color[u] = GRAY;
8688
+ stack.push(u);
8689
+ for (const v of adj[u]) {
8690
+ if (cycles.length >= limit) return;
8691
+ if (color[v] === GRAY) {
8692
+ const cycleStart = stack.indexOf(v);
8693
+ if (cycleStart >= 0) {
8694
+ const cyclePath = stack.slice(cycleStart);
8695
+ if (cyclePath.length <= maxLength) {
8696
+ const note = (idx) => index.notes.get(paths[idx])?.title || paths[idx];
8697
+ cycles.push({
8698
+ cycle: cyclePath.map(note),
8699
+ length: cyclePath.length
8700
+ });
8701
+ }
8702
+ }
8703
+ } else if (color[v] === WHITE) {
8704
+ dfs(v);
8705
+ }
8706
+ }
8707
+ stack.pop();
8708
+ color[u] = BLACK;
8709
+ }
8710
+ for (let i = 0; i < N; i++) {
8711
+ if (color[i] === WHITE && cycles.length < limit) {
8712
+ dfs(i);
8713
+ }
8714
+ }
8715
+ cycles.sort((a, b) => a.length - b.length);
8716
+ return cycles;
8717
+ }
8513
8718
 
8514
8719
  // src/core/read/indexGuard.ts
8515
8720
  function requireIndex() {
@@ -9802,6 +10007,7 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig2 = () =>
9802
10007
  });
9803
10008
  const HealthCheckOutputSchema = {
9804
10009
  status: z3.enum(["healthy", "degraded", "unhealthy"]).describe("Overall health status"),
10010
+ vault_health_score: z3.coerce.number().describe("Composite vault health score (0-100)"),
9805
10011
  schema_version: z3.coerce.number().describe("StateDb schema version"),
9806
10012
  vault_accessible: z3.boolean().describe("Whether the vault path is accessible"),
9807
10013
  vault_path: z3.string().describe("The vault path being used"),
@@ -10033,8 +10239,37 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig2 = () =>
10033
10239
  }
10034
10240
  }
10035
10241
  const topDeadLinkTargets = Array.from(deadTargetCounts.entries()).map(([target, mention_count]) => ({ target, mention_count })).sort((a, b) => b.mention_count - a.mention_count).slice(0, 5);
10242
+ let vault_health_score = 0;
10243
+ if (indexBuilt && noteCount > 0) {
10244
+ const avgOutlinks = linkCount / noteCount;
10245
+ const linkDensity = Math.min(1, avgOutlinks / 3);
10246
+ let orphanCount = 0;
10247
+ for (const note of index.notes.values()) {
10248
+ const bl = index.backlinks.get(note.title.toLowerCase());
10249
+ if (!bl || bl.length === 0) orphanCount++;
10250
+ }
10251
+ const orphanRatio = 1 - orphanCount / noteCount;
10252
+ const totalLinks = linkCount > 0 ? linkCount : 1;
10253
+ const deadLinkRatio = 1 - deadLinkCount / totalLinks;
10254
+ let notesWithFm = 0;
10255
+ for (const note of index.notes.values()) {
10256
+ if (Object.keys(note.frontmatter).length > 0) notesWithFm++;
10257
+ }
10258
+ const fmCoverage = notesWithFm / noteCount;
10259
+ const freshCutoff = Date.now() - 90 * 24 * 60 * 60 * 1e3;
10260
+ let freshCount = 0;
10261
+ for (const note of index.notes.values()) {
10262
+ if (note.modified && note.modified.getTime() > freshCutoff) freshCount++;
10263
+ }
10264
+ const freshness = freshCount / noteCount;
10265
+ const entityCoverage = Math.min(1, entityCount / (noteCount * 0.5));
10266
+ vault_health_score = Math.round(
10267
+ linkDensity * 25 + orphanRatio * 20 + deadLinkRatio * 15 + fmCoverage * 15 + freshness * 15 + entityCoverage * 10
10268
+ );
10269
+ }
10036
10270
  const output = {
10037
10271
  status,
10272
+ vault_health_score,
10038
10273
  schema_version: SCHEMA_VERSION,
10039
10274
  vault_accessible: vaultAccessible,
10040
10275
  vault_path: vaultPath2,
@@ -10485,7 +10720,7 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig2 = () =>
10485
10720
  import { z as z4 } from "zod";
10486
10721
  init_embeddings();
10487
10722
  import {
10488
- searchEntities,
10723
+ searchEntities as searchEntities2,
10489
10724
  searchEntitiesPrefix
10490
10725
  } from "@velvetmonkey/vault-core";
10491
10726
 
@@ -10494,7 +10729,6 @@ import {
10494
10729
  getEntityByName as getEntityByName2
10495
10730
  } from "@velvetmonkey/vault-core";
10496
10731
  var TOP_LINKS = 10;
10497
- var RECALL_TOP_LINKS = 5;
10498
10732
  function recencyDecay(modifiedDate) {
10499
10733
  if (!modifiedDate) return 0.5;
10500
10734
  const daysSince = (Date.now() - modifiedDate.getTime()) / (1e3 * 60 * 60 * 24);
@@ -10584,6 +10818,63 @@ function rankBacklinks(backlinks, notePath, index, stateDb2, maxLinks = TOP_LINK
10584
10818
  return out;
10585
10819
  }).sort((a, b) => (b.weight ?? 1) - (a.weight ?? 1)).slice(0, maxLinks);
10586
10820
  }
10821
+ var COMPACT_OUTLINK_NAMES = 10;
10822
+ function enrichResultCompact(result, index, stateDb2, opts) {
10823
+ const note = index.notes.get(result.path);
10824
+ const normalizedPath = result.path.toLowerCase().replace(/\.md$/, "");
10825
+ const backlinks = index.backlinks.get(normalizedPath) || [];
10826
+ const enriched = {
10827
+ path: result.path,
10828
+ title: result.title
10829
+ };
10830
+ if (result.snippet) {
10831
+ enriched.snippet = result.snippet;
10832
+ } else {
10833
+ const preview = getContentPreview(result.path);
10834
+ if (preview) enriched.snippet = preview;
10835
+ }
10836
+ if (note) {
10837
+ enriched.backlink_count = backlinks.length;
10838
+ enriched.modified = note.modified.toISOString();
10839
+ if (note.tags.length > 0) enriched.tags = note.tags;
10840
+ if (note.outlinks.length > 0) {
10841
+ enriched.outlink_names = getOutlinkNames(note.outlinks, result.path, index, stateDb2, COMPACT_OUTLINK_NAMES);
10842
+ }
10843
+ }
10844
+ if (stateDb2) {
10845
+ try {
10846
+ const entity = getEntityByName2(stateDb2, result.title);
10847
+ if (entity) {
10848
+ enriched.category = entity.category;
10849
+ enriched.hub_score = entity.hubScore;
10850
+ if (!enriched.snippet && entity.description) {
10851
+ enriched.snippet = entity.description;
10852
+ }
10853
+ }
10854
+ } catch {
10855
+ }
10856
+ }
10857
+ if (opts?.via) enriched.via = opts.via;
10858
+ if (opts?.hop) enriched.hop = opts.hop;
10859
+ return enriched;
10860
+ }
10861
+ function getOutlinkNames(outlinks, notePath, index, stateDb2, max) {
10862
+ const weightMap = /* @__PURE__ */ new Map();
10863
+ if (stateDb2) {
10864
+ try {
10865
+ const rows = stateDb2.db.prepare(
10866
+ "SELECT target, weight, weight_updated_at FROM note_links WHERE note_path = ?"
10867
+ ).all(notePath);
10868
+ for (const row of rows) {
10869
+ const daysSince = row.weight_updated_at ? (Date.now() - row.weight_updated_at) / (1e3 * 60 * 60 * 24) : 0;
10870
+ const decay = Math.max(0.1, 1 - daysSince / 180);
10871
+ weightMap.set(row.target, row.weight * decay);
10872
+ }
10873
+ } catch {
10874
+ }
10875
+ }
10876
+ return outlinks.map((l) => ({ name: l.target, weight: weightMap.get(l.target.toLowerCase()) ?? 1 })).sort((a, b) => b.weight - a.weight).slice(0, max).map((l) => l.name);
10877
+ }
10587
10878
  function enrichResult(result, index, stateDb2) {
10588
10879
  const note = index.notes.get(result.path);
10589
10880
  const normalizedPath = result.path.toLowerCase().replace(/\.md$/, "");
@@ -10651,7 +10942,7 @@ function enrichResultLight(result, index, stateDb2) {
10651
10942
  }
10652
10943
  return enriched;
10653
10944
  }
10654
- function enrichEntityResult(entityName, stateDb2, index) {
10945
+ function enrichEntityCompact(entityName, stateDb2, index) {
10655
10946
  const enriched = {};
10656
10947
  if (stateDb2) {
10657
10948
  try {
@@ -10673,36 +10964,27 @@ function enrichEntityResult(entityName, stateDb2, index) {
10673
10964
  const backlinks = index.backlinks.get(normalizedPath) || [];
10674
10965
  enriched.backlink_count = backlinks.length;
10675
10966
  if (note) {
10676
- enriched.outlink_count = note.outlinks.length;
10677
10967
  if (note.tags.length > 0) enriched.tags = note.tags;
10678
- if (backlinks.length > 0) {
10679
- enriched.top_backlinks = rankBacklinks(backlinks, entityPath, index, stateDb2, RECALL_TOP_LINKS);
10680
- }
10681
10968
  if (note.outlinks.length > 0) {
10682
- enriched.top_outlinks = rankOutlinks(note.outlinks, entityPath, index, stateDb2, RECALL_TOP_LINKS);
10969
+ enriched.outlink_names = getOutlinkNames(note.outlinks, entityPath, index, stateDb2, COMPACT_OUTLINK_NAMES);
10683
10970
  }
10684
10971
  }
10685
10972
  }
10686
10973
  }
10687
10974
  return enriched;
10688
10975
  }
10689
- function enrichNoteResult(notePath, stateDb2, index) {
10976
+ function enrichNoteCompact(notePath, stateDb2, index) {
10690
10977
  const enriched = {};
10691
10978
  if (!index) return enriched;
10692
10979
  const note = index.notes.get(notePath);
10693
10980
  if (!note) return enriched;
10694
10981
  const normalizedPath = notePath.toLowerCase().replace(/\.md$/, "");
10695
10982
  const backlinks = index.backlinks.get(normalizedPath) || [];
10696
- enriched.frontmatter = note.frontmatter;
10697
10983
  if (note.tags.length > 0) enriched.tags = note.tags;
10698
10984
  enriched.backlink_count = backlinks.length;
10699
- enriched.outlink_count = note.outlinks.length;
10700
10985
  enriched.modified = note.modified.toISOString();
10701
- if (backlinks.length > 0) {
10702
- enriched.top_backlinks = rankBacklinks(backlinks, notePath, index, stateDb2, RECALL_TOP_LINKS);
10703
- }
10704
10986
  if (note.outlinks.length > 0) {
10705
- enriched.top_outlinks = rankOutlinks(note.outlinks, notePath, index, stateDb2, RECALL_TOP_LINKS);
10987
+ enriched.outlink_names = getOutlinkNames(note.outlinks, notePath, index, stateDb2, COMPACT_OUTLINK_NAMES);
10706
10988
  }
10707
10989
  if (stateDb2) {
10708
10990
  try {
@@ -10717,8 +10999,132 @@ function enrichNoteResult(notePath, stateDb2, index) {
10717
10999
  return enriched;
10718
11000
  }
10719
11001
 
11002
+ // src/core/read/multihop.ts
11003
+ import { getEntityByName as getEntityByName3, searchEntities } from "@velvetmonkey/vault-core";
11004
+ var DEFAULT_CONFIG2 = {
11005
+ maxParents: 10,
11006
+ maxHops: 2,
11007
+ maxOutlinksPerHop: 10,
11008
+ maxBackfill: 10
11009
+ };
11010
+ function multiHopBackfill(primaryResults, index, stateDb2, config = {}) {
11011
+ const cfg = { ...DEFAULT_CONFIG2, ...config };
11012
+ const seen = new Set(primaryResults.map((r) => r.path).filter(Boolean));
11013
+ const candidates = [];
11014
+ const hop1Results = [];
11015
+ for (const primary of primaryResults.slice(0, cfg.maxParents)) {
11016
+ const primaryPath = primary.path;
11017
+ if (!primaryPath) continue;
11018
+ const note = index.notes.get(primaryPath);
11019
+ if (!note) continue;
11020
+ for (const outlink of note.outlinks.slice(0, cfg.maxOutlinksPerHop)) {
11021
+ const targetPath = index.entities.get(outlink.target.toLowerCase());
11022
+ if (!targetPath || seen.has(targetPath)) continue;
11023
+ seen.add(targetPath);
11024
+ const targetNote = index.notes.get(targetPath);
11025
+ const title = targetNote?.title ?? outlink.target;
11026
+ hop1Results.push({ path: targetPath, title, via: primaryPath });
11027
+ }
11028
+ }
11029
+ for (const h1 of hop1Results) {
11030
+ const enriched = enrichResultCompact(
11031
+ { path: h1.path, title: h1.title },
11032
+ index,
11033
+ stateDb2,
11034
+ { via: h1.via, hop: 1 }
11035
+ );
11036
+ const score = scoreCandidate(h1.path, index, stateDb2);
11037
+ candidates.push({ result: enriched, score });
11038
+ }
11039
+ if (cfg.maxHops >= 2) {
11040
+ for (const h1 of hop1Results) {
11041
+ const note = index.notes.get(h1.path);
11042
+ if (!note) continue;
11043
+ for (const outlink of note.outlinks.slice(0, cfg.maxOutlinksPerHop)) {
11044
+ const targetPath = index.entities.get(outlink.target.toLowerCase());
11045
+ if (!targetPath || seen.has(targetPath)) continue;
11046
+ seen.add(targetPath);
11047
+ const targetNote = index.notes.get(targetPath);
11048
+ const title = targetNote?.title ?? outlink.target;
11049
+ const enriched = enrichResultCompact(
11050
+ { path: targetPath, title },
11051
+ index,
11052
+ stateDb2,
11053
+ { via: h1.path, hop: 2 }
11054
+ );
11055
+ const score = scoreCandidate(targetPath, index, stateDb2);
11056
+ candidates.push({ result: enriched, score });
11057
+ }
11058
+ }
11059
+ }
11060
+ candidates.sort((a, b) => b.score - a.score);
11061
+ return candidates.slice(0, cfg.maxBackfill).map((c) => c.result);
11062
+ }
11063
+ function scoreCandidate(path33, index, stateDb2) {
11064
+ const note = index.notes.get(path33);
11065
+ const decay = recencyDecay(note?.modified);
11066
+ let hubScore = 1;
11067
+ if (stateDb2) {
11068
+ try {
11069
+ const title = note?.title ?? path33.replace(/\.md$/, "").split("/").pop() ?? "";
11070
+ const entity = getEntityByName3(stateDb2, title);
11071
+ if (entity) hubScore = entity.hubScore ?? 1;
11072
+ } catch {
11073
+ }
11074
+ }
11075
+ return hubScore * decay;
11076
+ }
11077
+ function extractExpansionTerms(results, originalQuery, index) {
11078
+ const queryLower = originalQuery.toLowerCase();
11079
+ const terms = /* @__PURE__ */ new Set();
11080
+ for (const r of results.slice(0, 5)) {
11081
+ const outlinks = r.outlink_names;
11082
+ if (outlinks) {
11083
+ for (const name of outlinks) {
11084
+ if (!queryLower.includes(name.toLowerCase()) && index.entities.has(name.toLowerCase())) {
11085
+ terms.add(name);
11086
+ }
11087
+ }
11088
+ }
11089
+ const snippet = r.snippet;
11090
+ if (snippet) {
11091
+ const matches = snippet.match(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g);
11092
+ if (matches) {
11093
+ for (const wl of matches) {
11094
+ const name = wl.replace(/\[\[|\]\]/g, "").split("|")[0];
11095
+ if (!queryLower.includes(name.toLowerCase()) && index.entities.has(name.toLowerCase())) {
11096
+ terms.add(name);
11097
+ }
11098
+ }
11099
+ }
11100
+ }
11101
+ }
11102
+ return Array.from(terms).slice(0, 10);
11103
+ }
11104
+ function expandQuery(expansionTerms, primaryResults, index, stateDb2) {
11105
+ if (!stateDb2 || expansionTerms.length === 0) return [];
11106
+ const seen = new Set(primaryResults.map((r) => r.path).filter(Boolean));
11107
+ const results = [];
11108
+ for (const term of expansionTerms) {
11109
+ try {
11110
+ const entities = searchEntities(stateDb2, term, 3);
11111
+ for (const entity of entities) {
11112
+ if (!entity.path || seen.has(entity.path)) continue;
11113
+ seen.add(entity.path);
11114
+ results.push(enrichResultCompact(
11115
+ { path: entity.path, title: entity.name },
11116
+ index,
11117
+ stateDb2,
11118
+ { via: "query_expansion" }
11119
+ ));
11120
+ }
11121
+ } catch {
11122
+ }
11123
+ }
11124
+ return results;
11125
+ }
11126
+
10720
11127
  // src/tools/read/query.ts
10721
- init_wikilinkFeedback();
10722
11128
  function matchesFrontmatter(note, where) {
10723
11129
  for (const [key, value] of Object.entries(where)) {
10724
11130
  const noteValue = note.frontmatter[key];
@@ -10899,7 +11305,7 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb2) {
10899
11305
  const stateDbEntity = getStateDb2();
10900
11306
  if (stateDbEntity) {
10901
11307
  try {
10902
- entityResults = searchEntities(stateDbEntity, query, limit);
11308
+ entityResults = searchEntities2(stateDbEntity, query, limit);
10903
11309
  } catch {
10904
11310
  }
10905
11311
  }
@@ -10971,46 +11377,22 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb2) {
10971
11377
  scored.sort((a, b) => b.rrf_score - a.rrf_score);
10972
11378
  const filtered = applyFolderFilter(scored);
10973
11379
  const stateDb2 = getStateDb2();
10974
- const results = filtered.slice(0, limit).map((item, i) => ({
10975
- ...(i < detailN ? enrichResult : enrichResultLight)({ path: item.path, title: item.title, snippet: item.snippet }, index, stateDb2),
11380
+ const results2 = filtered.slice(0, limit).map((item) => ({
11381
+ ...enrichResultCompact({ path: item.path, title: item.title, snippet: item.snippet }, index, stateDb2),
10976
11382
  rrf_score: item.rrf_score,
10977
11383
  in_fts5: item.in_fts5,
10978
11384
  in_semantic: item.in_semantic,
10979
11385
  in_entity: item.in_entity
10980
11386
  }));
10981
- if (stateDb2 && results.length < limit) {
10982
- const existingPaths = new Set(results.map((r) => r.path));
10983
- const backfill = [];
10984
- for (const r of results.slice(0, 3)) {
10985
- const rPath = r.path;
10986
- if (!rPath) continue;
10987
- try {
10988
- const outlinks = getStoredNoteLinks(stateDb2, rPath);
10989
- for (const target of outlinks) {
10990
- const entityRow = stateDb2.db.prepare(
10991
- "SELECT path FROM entities WHERE name_lower = ?"
10992
- ).get(target);
10993
- if (entityRow?.path && !existingPaths.has(entityRow.path)) {
10994
- existingPaths.add(entityRow.path);
10995
- backfill.push({
10996
- ...enrichResultLight({ path: entityRow.path, title: target }, index, stateDb2),
10997
- rrf_score: 0,
10998
- in_fts5: false,
10999
- in_semantic: false,
11000
- in_entity: false
11001
- });
11002
- }
11003
- }
11004
- } catch {
11005
- }
11006
- }
11007
- results.push(...backfill.slice(0, limit - results.length));
11008
- }
11387
+ const hopResults2 = multiHopBackfill(results2, index, stateDb2, { maxBackfill: limit });
11388
+ const expansionTerms2 = extractExpansionTerms(results2, query, index);
11389
+ const expansionResults2 = expandQuery(expansionTerms2, [...results2, ...hopResults2], index, stateDb2);
11390
+ results2.push(...hopResults2, ...expansionResults2);
11009
11391
  return { content: [{ type: "text", text: JSON.stringify({
11010
11392
  method: "hybrid",
11011
11393
  query,
11012
11394
  total_results: filtered.length,
11013
- results
11395
+ results: results2
11014
11396
  }, null, 2) }] };
11015
11397
  } catch (err) {
11016
11398
  console.error("[Semantic] Hybrid search failed, falling back to FTS5:", err instanceof Error ? err.message : err);
@@ -11027,49 +11409,33 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb2) {
11027
11409
  const filtered = applyFolderFilter(mergedItems);
11028
11410
  const stateDb2 = getStateDb2();
11029
11411
  const sliced = filtered.slice(0, limit);
11030
- const results = sliced.map((item, i) => ({
11031
- ...(i < detailN ? enrichResult : enrichResultLight)({ path: item.path, title: item.title, snippet: item.snippet }, index, stateDb2),
11412
+ const results2 = sliced.map((item) => ({
11413
+ ...enrichResultCompact({ path: item.path, title: item.title, snippet: item.snippet }, index, stateDb2),
11032
11414
  ..."in_fts5" in item ? { in_fts5: true } : { in_entity: true }
11033
11415
  }));
11034
- if (stateDb2 && results.length < limit) {
11035
- const existingPaths = new Set(results.map((r) => r.path));
11036
- const backfill = [];
11037
- for (const r of results.slice(0, 3)) {
11038
- const rPath = r.path;
11039
- if (!rPath) continue;
11040
- try {
11041
- const outlinks = getStoredNoteLinks(stateDb2, rPath);
11042
- for (const target of outlinks) {
11043
- const entityRow = stateDb2.db.prepare(
11044
- "SELECT path FROM entities WHERE name_lower = ?"
11045
- ).get(target);
11046
- if (entityRow?.path && !existingPaths.has(entityRow.path)) {
11047
- existingPaths.add(entityRow.path);
11048
- backfill.push({
11049
- ...enrichResultLight({ path: entityRow.path, title: target }, index, stateDb2)
11050
- });
11051
- }
11052
- }
11053
- } catch {
11054
- }
11055
- }
11056
- results.push(...backfill.slice(0, limit - results.length));
11057
- }
11416
+ const hopResults2 = multiHopBackfill(results2, index, stateDb2, { maxBackfill: limit });
11417
+ const expansionTerms2 = extractExpansionTerms(results2, query, index);
11418
+ const expansionResults2 = expandQuery(expansionTerms2, [...results2, ...hopResults2], index, stateDb2);
11419
+ results2.push(...hopResults2, ...expansionResults2);
11058
11420
  return { content: [{ type: "text", text: JSON.stringify({
11059
11421
  method: "fts5",
11060
11422
  query,
11061
11423
  total_results: filtered.length,
11062
- results
11424
+ results: results2
11063
11425
  }, null, 2) }] };
11064
11426
  }
11065
11427
  const stateDbFts = getStateDb2();
11066
11428
  const fts5Filtered = applyFolderFilter(fts5Results);
11067
- const enrichedFts5 = fts5Filtered.map((r, i) => ({ ...(i < detailN ? enrichResult : enrichResultLight)({ path: r.path, title: r.title, snippet: r.snippet }, index, stateDbFts), in_fts5: true }));
11429
+ const results = fts5Filtered.map((r) => ({ ...enrichResultCompact({ path: r.path, title: r.title, snippet: r.snippet }, index, stateDbFts), in_fts5: true }));
11430
+ const hopResults = multiHopBackfill(results, index, stateDbFts, { maxBackfill: limit });
11431
+ const expansionTerms = extractExpansionTerms(results, query, index);
11432
+ const expansionResults = expandQuery(expansionTerms, [...results, ...hopResults], index, stateDbFts);
11433
+ results.push(...hopResults, ...expansionResults);
11068
11434
  return { content: [{ type: "text", text: JSON.stringify({
11069
11435
  method: "fts5",
11070
11436
  query,
11071
- total_results: enrichedFts5.length,
11072
- results: enrichedFts5
11437
+ total_results: results.length,
11438
+ results
11073
11439
  }, null, 2) }] };
11074
11440
  }
11075
11441
  return { content: [{ type: "text", text: JSON.stringify({ error: "Provide a query or metadata filters (where, has_tag, folder, etc.)" }, null, 2) }] };
@@ -11795,7 +12161,7 @@ async function findSections(index, headingPattern, vaultPath2, folder) {
11795
12161
  }
11796
12162
 
11797
12163
  // src/tools/read/primitives.ts
11798
- import { getEntityByName as getEntityByName3 } from "@velvetmonkey/vault-core";
12164
+ import { getEntityByName as getEntityByName4 } from "@velvetmonkey/vault-core";
11799
12165
  function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig2 = () => ({}), getStateDb2 = () => null) {
11800
12166
  server2.registerTool(
11801
12167
  "get_note_structure",
@@ -11838,7 +12204,7 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig2 = ()
11838
12204
  const stateDb2 = getStateDb2();
11839
12205
  if (stateDb2 && note) {
11840
12206
  try {
11841
- const entity = getEntityByName3(stateDb2, note.title);
12207
+ const entity = getEntityByName4(stateDb2, note.title);
11842
12208
  if (entity) {
11843
12209
  enriched.category = entity.category;
11844
12210
  enriched.hub_score = entity.hubScore;
@@ -12899,9 +13265,9 @@ function registerGraphAnalysisTools(server2, getIndex, getVaultPath, getStateDb2
12899
13265
  "graph_analysis",
12900
13266
  {
12901
13267
  title: "Graph Analysis",
12902
- description: 'Analyze vault link graph structure. Use analysis to pick the mode:\n- "orphans": Notes with no backlinks (params: folder, limit, offset)\n- "dead_ends": Notes with backlinks but no outgoing links (params: folder, min_backlinks, limit, offset)\n- "sources": Notes with outgoing links but no backlinks (params: folder, min_outlinks, limit, offset)\n- "hubs": Highly connected notes (params: min_links, limit, offset)\n- "stale": Important notes not recently modified (params: days [required], min_backlinks, limit, offset)\n- "immature": Notes scored by maturity (params: folder, limit, offset)\n- "emerging_hubs": Entities growing fastest in connection count (params: days, limit, offset)',
13268
+ description: 'Analyze vault link graph structure. Use analysis to pick the mode:\n- "orphans": Notes with no backlinks (params: folder, limit, offset)\n- "dead_ends": Notes with backlinks but no outgoing links (params: folder, min_backlinks, limit, offset)\n- "sources": Notes with outgoing links but no backlinks (params: folder, min_outlinks, limit, offset)\n- "hubs": Highly connected notes (params: min_links, limit, offset)\n- "stale": Important notes not recently modified (params: days [required], min_backlinks, limit, offset)\n- "immature": Notes scored by maturity (params: folder, limit, offset)\n- "emerging_hubs": Entities growing fastest in connection count (params: days, limit, offset)\n- "centrality": Degree, betweenness, and closeness centrality metrics (params: limit, offset)\n- "cycles": Detect circular link chains in the vault (params: limit)',
12903
13269
  inputSchema: {
12904
- analysis: z8.enum(["orphans", "dead_ends", "sources", "hubs", "stale", "immature", "emerging_hubs"]).describe("Type of graph analysis to perform"),
13270
+ analysis: z8.enum(["orphans", "dead_ends", "sources", "hubs", "stale", "immature", "emerging_hubs", "centrality", "cycles"]).describe("Type of graph analysis to perform"),
12905
13271
  folder: z8.string().optional().describe("Limit to notes in this folder (orphans, dead_ends, sources, immature)"),
12906
13272
  min_links: z8.coerce.number().default(5).describe("Minimum total connections for hubs"),
12907
13273
  min_backlinks: z8.coerce.number().default(1).describe("Minimum backlinks (dead_ends, stale)"),
@@ -13100,6 +13466,28 @@ function registerGraphAnalysisTools(server2, getIndex, getVaultPath, getStateDb2
13100
13466
  }, null, 2) }]
13101
13467
  };
13102
13468
  }
13469
+ case "centrality": {
13470
+ const results = computeCentralityMetrics(index, limit);
13471
+ const paginated = results.slice(offset, offset + limit);
13472
+ return {
13473
+ content: [{ type: "text", text: JSON.stringify({
13474
+ analysis: "centrality",
13475
+ total_count: results.length,
13476
+ returned_count: paginated.length,
13477
+ notes: paginated
13478
+ }, null, 2) }]
13479
+ };
13480
+ }
13481
+ case "cycles": {
13482
+ const cycles = detectCycles(index, 10, limit);
13483
+ return {
13484
+ content: [{ type: "text", text: JSON.stringify({
13485
+ analysis: "cycles",
13486
+ total_count: cycles.length,
13487
+ cycles
13488
+ }, null, 2) }]
13489
+ };
13490
+ }
13103
13491
  }
13104
13492
  }
13105
13493
  );
@@ -19068,13 +19456,13 @@ function registerRecallTools(server2, getStateDb2, getVaultPath, getIndex) {
19068
19456
  description: e.content,
19069
19457
  score: Math.round(e.score * 10) / 10,
19070
19458
  breakdown: e.breakdown,
19071
- ...enrichEntityResult(e.id, stateDb2, index)
19459
+ ...enrichEntityCompact(e.id, stateDb2, index)
19072
19460
  })),
19073
19461
  notes: notes.map((n) => ({
19074
19462
  path: n.id,
19075
19463
  snippet: n.content,
19076
19464
  score: Math.round(n.score * 10) / 10,
19077
- ...enrichNoteResult(n.id, stateDb2, index)
19465
+ ...enrichNoteCompact(n.id, stateDb2, index)
19078
19466
  })),
19079
19467
  memories: memories.map((m) => ({
19080
19468
  key: m.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.0.121",
3
+ "version": "2.0.123",
4
4
  "description": "MCP server that gives Claude full read/write access to your Obsidian vault. Select from 69 tools for search, backlinks, graph queries, mutations, agent memory, and hybrid semantic search.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -49,11 +49,11 @@
49
49
  "lint": "tsc --noEmit",
50
50
  "clean": "rm -rf dist",
51
51
  "postinstall": "node -e \"['bin/flywheel-memory.js','dist/index.js'].forEach(f=>{try{require('fs').chmodSync(f,0o755)}catch{}})\"",
52
- "prepublishOnly": "npm run build"
52
+ "prepublishOnly": "npm run lint && npm run build"
53
53
  },
54
54
  "dependencies": {
55
55
  "@modelcontextprotocol/sdk": "^1.25.1",
56
- "@velvetmonkey/vault-core": "^2.0.121",
56
+ "@velvetmonkey/vault-core": "^2.0.122",
57
57
  "better-sqlite3": "^11.0.0",
58
58
  "chokidar": "^4.0.0",
59
59
  "gray-matter": "^4.0.3",