@velvetmonkey/flywheel-memory 2.0.120 → 2.0.122

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 +272 -14
  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));
7584
7589
  }
7585
7590
  }
7586
- return hubScores;
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
+ }
7606
+ }
7607
+ }
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"),
@@ -9908,17 +10114,18 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig2 = () =>
9908
10114
  vaultAccessible = false;
9909
10115
  recommendations.push("Vault path is not accessible. Check PROJECT_PATH environment variable.");
9910
10116
  }
10117
+ let dbIntegrityFailed = false;
9911
10118
  const stateDb2 = getStateDb2();
9912
10119
  if (stateDb2) {
9913
10120
  try {
9914
10121
  const result = stateDb2.db.pragma("quick_check");
9915
10122
  const ok = result.length === 1 && Object.values(result[0])[0] === "ok";
9916
10123
  if (!ok) {
9917
- overall = "unhealthy";
10124
+ dbIntegrityFailed = true;
9918
10125
  recommendations.push(`Database integrity check failed: ${Object.values(result[0])[0] ?? "unknown error"}`);
9919
10126
  }
9920
10127
  } catch (err) {
9921
- overall = "unhealthy";
10128
+ dbIntegrityFailed = true;
9922
10129
  recommendations.push(`Database integrity check error: ${err instanceof Error ? err.message : err}`);
9923
10130
  }
9924
10131
  }
@@ -9945,7 +10152,7 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig2 = () =>
9945
10152
  recommendations.push("No notes found in vault. Is PROJECT_PATH pointing to a markdown vault?");
9946
10153
  }
9947
10154
  let status;
9948
- if (!vaultAccessible || indexState2 === "error") {
10155
+ if (!vaultAccessible || indexState2 === "error" || dbIntegrityFailed) {
9949
10156
  status = "unhealthy";
9950
10157
  } else if (indexState2 === "building" || indexStale || recommendations.length > 0) {
9951
10158
  status = "degraded";
@@ -10032,8 +10239,37 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig2 = () =>
10032
10239
  }
10033
10240
  }
10034
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
+ }
10035
10270
  const output = {
10036
10271
  status,
10272
+ vault_health_score,
10037
10273
  schema_version: SCHEMA_VERSION,
10038
10274
  vault_accessible: vaultAccessible,
10039
10275
  vault_path: vaultPath2,
@@ -12898,9 +13134,9 @@ function registerGraphAnalysisTools(server2, getIndex, getVaultPath, getStateDb2
12898
13134
  "graph_analysis",
12899
13135
  {
12900
13136
  title: "Graph Analysis",
12901
- 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)',
13137
+ 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)',
12902
13138
  inputSchema: {
12903
- analysis: z8.enum(["orphans", "dead_ends", "sources", "hubs", "stale", "immature", "emerging_hubs"]).describe("Type of graph analysis to perform"),
13139
+ analysis: z8.enum(["orphans", "dead_ends", "sources", "hubs", "stale", "immature", "emerging_hubs", "centrality", "cycles"]).describe("Type of graph analysis to perform"),
12904
13140
  folder: z8.string().optional().describe("Limit to notes in this folder (orphans, dead_ends, sources, immature)"),
12905
13141
  min_links: z8.coerce.number().default(5).describe("Minimum total connections for hubs"),
12906
13142
  min_backlinks: z8.coerce.number().default(1).describe("Minimum backlinks (dead_ends, stale)"),
@@ -13099,6 +13335,28 @@ function registerGraphAnalysisTools(server2, getIndex, getVaultPath, getStateDb2
13099
13335
  }, null, 2) }]
13100
13336
  };
13101
13337
  }
13338
+ case "centrality": {
13339
+ const results = computeCentralityMetrics(index, limit);
13340
+ const paginated = results.slice(offset, offset + limit);
13341
+ return {
13342
+ content: [{ type: "text", text: JSON.stringify({
13343
+ analysis: "centrality",
13344
+ total_count: results.length,
13345
+ returned_count: paginated.length,
13346
+ notes: paginated
13347
+ }, null, 2) }]
13348
+ };
13349
+ }
13350
+ case "cycles": {
13351
+ const cycles = detectCycles(index, 10, limit);
13352
+ return {
13353
+ content: [{ type: "text", text: JSON.stringify({
13354
+ analysis: "cycles",
13355
+ total_count: cycles.length,
13356
+ cycles
13357
+ }, null, 2) }]
13358
+ };
13359
+ }
13102
13360
  }
13103
13361
  }
13104
13362
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.0.120",
3
+ "version": "2.0.122",
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.120",
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",