@velvetmonkey/flywheel-memory 2.0.44 → 2.0.45

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 +270 -113
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -836,8 +836,8 @@ function createContext(variables = {}) {
836
836
  steps: {}
837
837
  };
838
838
  }
839
- function resolvePath(obj, path32) {
840
- const parts = path32.split(".");
839
+ function resolvePath(obj, path33) {
840
+ const parts = path33.split(".");
841
841
  let current = obj;
842
842
  for (const part of parts) {
843
843
  if (current === void 0 || current === null) {
@@ -1552,10 +1552,10 @@ var init_taskHelpers = __esm({
1552
1552
  });
1553
1553
 
1554
1554
  // src/index.ts
1555
- import * as path31 from "path";
1555
+ import * as path32 from "path";
1556
1556
  import { readFileSync as readFileSync4, realpathSync } from "fs";
1557
1557
  import { fileURLToPath } from "url";
1558
- import { dirname as dirname4, join as join16 } from "path";
1558
+ import { dirname as dirname4, join as join17 } from "path";
1559
1559
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1560
1560
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1561
1561
 
@@ -2216,8 +2216,8 @@ function updateIndexProgress(parsed, total) {
2216
2216
  function normalizeTarget(target) {
2217
2217
  return target.toLowerCase().replace(/\.md$/, "");
2218
2218
  }
2219
- function normalizeNotePath(path32) {
2220
- return path32.toLowerCase().replace(/\.md$/, "");
2219
+ function normalizeNotePath(path33) {
2220
+ return path33.toLowerCase().replace(/\.md$/, "");
2221
2221
  }
2222
2222
  async function buildVaultIndex(vaultPath2, options = {}) {
2223
2223
  const { timeoutMs = DEFAULT_TIMEOUT_MS, onProgress } = options;
@@ -2386,7 +2386,7 @@ function findSimilarEntity(index, target) {
2386
2386
  }
2387
2387
  const maxDist = normalizedLen <= 10 ? 1 : 2;
2388
2388
  let bestMatch;
2389
- for (const [entity, path32] of index.entities) {
2389
+ for (const [entity, path33] of index.entities) {
2390
2390
  const lenDiff = Math.abs(entity.length - normalizedLen);
2391
2391
  if (lenDiff > maxDist) {
2392
2392
  continue;
@@ -2394,7 +2394,7 @@ function findSimilarEntity(index, target) {
2394
2394
  const dist = levenshteinDistance(normalized, entity);
2395
2395
  if (dist > 0 && dist <= maxDist) {
2396
2396
  if (!bestMatch || dist < bestMatch.distance) {
2397
- bestMatch = { path: path32, entity, distance: dist };
2397
+ bestMatch = { path: path33, entity, distance: dist };
2398
2398
  if (dist === 1) {
2399
2399
  return bestMatch;
2400
2400
  }
@@ -2783,8 +2783,8 @@ function normalizePath(filePath) {
2783
2783
  return normalized;
2784
2784
  }
2785
2785
  function getRelativePath(vaultPath2, filePath) {
2786
- const relative2 = path5.relative(vaultPath2, filePath);
2787
- return normalizePath(relative2);
2786
+ const relative3 = path5.relative(vaultPath2, filePath);
2787
+ return normalizePath(relative3);
2788
2788
  }
2789
2789
  function shouldWatch(filePath, vaultPath2) {
2790
2790
  const normalized = normalizePath(filePath);
@@ -2913,30 +2913,30 @@ var EventQueue = class {
2913
2913
  * Add a new event to the queue
2914
2914
  */
2915
2915
  push(type, rawPath) {
2916
- const path32 = normalizePath(rawPath);
2916
+ const path33 = normalizePath(rawPath);
2917
2917
  const now = Date.now();
2918
2918
  const event = {
2919
2919
  type,
2920
- path: path32,
2920
+ path: path33,
2921
2921
  timestamp: now
2922
2922
  };
2923
- let pending = this.pending.get(path32);
2923
+ let pending = this.pending.get(path33);
2924
2924
  if (!pending) {
2925
2925
  pending = {
2926
2926
  events: [],
2927
2927
  timer: null,
2928
2928
  lastEvent: now
2929
2929
  };
2930
- this.pending.set(path32, pending);
2930
+ this.pending.set(path33, pending);
2931
2931
  }
2932
2932
  pending.events.push(event);
2933
2933
  pending.lastEvent = now;
2934
- console.error(`[flywheel] QUEUE: pushed ${type} for ${path32}, pending=${this.pending.size}`);
2934
+ console.error(`[flywheel] QUEUE: pushed ${type} for ${path33}, pending=${this.pending.size}`);
2935
2935
  if (pending.timer) {
2936
2936
  clearTimeout(pending.timer);
2937
2937
  }
2938
2938
  pending.timer = setTimeout(() => {
2939
- this.flushPath(path32);
2939
+ this.flushPath(path33);
2940
2940
  }, this.config.debounceMs);
2941
2941
  if (this.pending.size >= this.config.batchSize) {
2942
2942
  this.flush();
@@ -2957,10 +2957,10 @@ var EventQueue = class {
2957
2957
  /**
2958
2958
  * Flush a single path's events
2959
2959
  */
2960
- flushPath(path32) {
2961
- const pending = this.pending.get(path32);
2960
+ flushPath(path33) {
2961
+ const pending = this.pending.get(path33);
2962
2962
  if (!pending || pending.events.length === 0) return;
2963
- console.error(`[flywheel] QUEUE: flushing ${path32}, events=${pending.events.length}`);
2963
+ console.error(`[flywheel] QUEUE: flushing ${path33}, events=${pending.events.length}`);
2964
2964
  if (pending.timer) {
2965
2965
  clearTimeout(pending.timer);
2966
2966
  pending.timer = null;
@@ -2969,7 +2969,7 @@ var EventQueue = class {
2969
2969
  if (coalescedType) {
2970
2970
  const coalesced = {
2971
2971
  type: coalescedType,
2972
- path: path32,
2972
+ path: path33,
2973
2973
  originalEvents: [...pending.events]
2974
2974
  };
2975
2975
  this.onBatch({
@@ -2978,7 +2978,7 @@ var EventQueue = class {
2978
2978
  timestamp: Date.now()
2979
2979
  });
2980
2980
  }
2981
- this.pending.delete(path32);
2981
+ this.pending.delete(path33);
2982
2982
  }
2983
2983
  /**
2984
2984
  * Flush all pending events
@@ -2990,7 +2990,7 @@ var EventQueue = class {
2990
2990
  }
2991
2991
  if (this.pending.size === 0) return;
2992
2992
  const events = [];
2993
- for (const [path32, pending] of this.pending) {
2993
+ for (const [path33, pending] of this.pending) {
2994
2994
  if (pending.timer) {
2995
2995
  clearTimeout(pending.timer);
2996
2996
  }
@@ -2998,7 +2998,7 @@ var EventQueue = class {
2998
2998
  if (coalescedType) {
2999
2999
  events.push({
3000
3000
  type: coalescedType,
3001
- path: path32,
3001
+ path: path33,
3002
3002
  originalEvents: [...pending.events]
3003
3003
  });
3004
3004
  }
@@ -3168,8 +3168,8 @@ async function upsertNote(index, vaultPath2, notePath) {
3168
3168
  removeNoteFromIndex(index, notePath);
3169
3169
  }
3170
3170
  const fullPath = path7.join(vaultPath2, notePath);
3171
- const fs31 = await import("fs/promises");
3172
- const stats = await fs31.stat(fullPath);
3171
+ const fs32 = await import("fs/promises");
3172
+ const stats = await fs32.stat(fullPath);
3173
3173
  const vaultFile = {
3174
3174
  path: notePath,
3175
3175
  absolutePath: fullPath,
@@ -3351,31 +3351,31 @@ function createVaultWatcher(options) {
3351
3351
  usePolling: config.usePolling,
3352
3352
  interval: config.usePolling ? config.pollInterval : void 0
3353
3353
  });
3354
- watcher.on("add", (path32) => {
3355
- console.error(`[flywheel] RAW EVENT: add ${path32}`);
3356
- if (shouldWatch(path32, vaultPath2)) {
3357
- console.error(`[flywheel] ACCEPTED: add ${path32}`);
3358
- eventQueue.push("add", path32);
3354
+ watcher.on("add", (path33) => {
3355
+ console.error(`[flywheel] RAW EVENT: add ${path33}`);
3356
+ if (shouldWatch(path33, vaultPath2)) {
3357
+ console.error(`[flywheel] ACCEPTED: add ${path33}`);
3358
+ eventQueue.push("add", path33);
3359
3359
  } else {
3360
- console.error(`[flywheel] FILTERED: add ${path32}`);
3360
+ console.error(`[flywheel] FILTERED: add ${path33}`);
3361
3361
  }
3362
3362
  });
3363
- watcher.on("change", (path32) => {
3364
- console.error(`[flywheel] RAW EVENT: change ${path32}`);
3365
- if (shouldWatch(path32, vaultPath2)) {
3366
- console.error(`[flywheel] ACCEPTED: change ${path32}`);
3367
- eventQueue.push("change", path32);
3363
+ watcher.on("change", (path33) => {
3364
+ console.error(`[flywheel] RAW EVENT: change ${path33}`);
3365
+ if (shouldWatch(path33, vaultPath2)) {
3366
+ console.error(`[flywheel] ACCEPTED: change ${path33}`);
3367
+ eventQueue.push("change", path33);
3368
3368
  } else {
3369
- console.error(`[flywheel] FILTERED: change ${path32}`);
3369
+ console.error(`[flywheel] FILTERED: change ${path33}`);
3370
3370
  }
3371
3371
  });
3372
- watcher.on("unlink", (path32) => {
3373
- console.error(`[flywheel] RAW EVENT: unlink ${path32}`);
3374
- if (shouldWatch(path32, vaultPath2)) {
3375
- console.error(`[flywheel] ACCEPTED: unlink ${path32}`);
3376
- eventQueue.push("unlink", path32);
3372
+ watcher.on("unlink", (path33) => {
3373
+ console.error(`[flywheel] RAW EVENT: unlink ${path33}`);
3374
+ if (shouldWatch(path33, vaultPath2)) {
3375
+ console.error(`[flywheel] ACCEPTED: unlink ${path33}`);
3376
+ eventQueue.push("unlink", path33);
3377
3377
  } else {
3378
- console.error(`[flywheel] FILTERED: unlink ${path32}`);
3378
+ console.error(`[flywheel] FILTERED: unlink ${path33}`);
3379
3379
  }
3380
3380
  });
3381
3381
  watcher.on("ready", () => {
@@ -3681,10 +3681,10 @@ function getAllFeedbackBoosts(stateDb2, folder) {
3681
3681
  for (const row of globalRows) {
3682
3682
  let accuracy;
3683
3683
  let sampleCount;
3684
- const fs31 = folderStats?.get(row.entity);
3685
- if (fs31 && fs31.count >= FEEDBACK_BOOST_MIN_SAMPLES) {
3686
- accuracy = fs31.accuracy;
3687
- sampleCount = fs31.count;
3684
+ const fs32 = folderStats?.get(row.entity);
3685
+ if (fs32 && fs32.count >= FEEDBACK_BOOST_MIN_SAMPLES) {
3686
+ accuracy = fs32.accuracy;
3687
+ sampleCount = fs32.count;
3688
3688
  } else {
3689
3689
  accuracy = row.correct_count / row.total;
3690
3690
  sampleCount = row.total;
@@ -7898,14 +7898,14 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
7898
7898
  };
7899
7899
  function findSimilarEntity2(target, entities) {
7900
7900
  const targetLower = target.toLowerCase();
7901
- for (const [name, path32] of entities) {
7901
+ for (const [name, path33] of entities) {
7902
7902
  if (name.startsWith(targetLower) || targetLower.startsWith(name)) {
7903
- return path32;
7903
+ return path33;
7904
7904
  }
7905
7905
  }
7906
- for (const [name, path32] of entities) {
7906
+ for (const [name, path33] of entities) {
7907
7907
  if (name.includes(targetLower) || targetLower.includes(name)) {
7908
- return path32;
7908
+ return path33;
7909
7909
  }
7910
7910
  }
7911
7911
  return void 0;
@@ -8882,8 +8882,8 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
8882
8882
  daily_counts: z3.record(z3.number())
8883
8883
  }).describe("Activity summary for the last 7 days")
8884
8884
  };
8885
- function isPeriodicNote2(path32) {
8886
- const filename = path32.split("/").pop() || "";
8885
+ function isPeriodicNote2(path33) {
8886
+ const filename = path33.split("/").pop() || "";
8887
8887
  const nameWithoutExt = filename.replace(/\.md$/, "");
8888
8888
  const patterns = [
8889
8889
  /^\d{4}-\d{2}-\d{2}$/,
@@ -8898,7 +8898,7 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
8898
8898
  // YYYY (yearly)
8899
8899
  ];
8900
8900
  const periodicFolders = ["daily", "weekly", "monthly", "quarterly", "yearly", "journal", "journals"];
8901
- const folder = path32.split("/")[0]?.toLowerCase() || "";
8901
+ const folder = path33.split("/")[0]?.toLowerCase() || "";
8902
8902
  return patterns.some((p) => p.test(nameWithoutExt)) || periodicFolders.includes(folder);
8903
8903
  }
8904
8904
  server2.registerTool(
@@ -10150,18 +10150,18 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
10150
10150
  include_content: z6.boolean().default(false).describe("Include the text content under each top-level section")
10151
10151
  }
10152
10152
  },
10153
- async ({ path: path32, include_content }) => {
10153
+ async ({ path: path33, include_content }) => {
10154
10154
  const index = getIndex();
10155
10155
  const vaultPath2 = getVaultPath();
10156
- const result = await getNoteStructure(index, path32, vaultPath2);
10156
+ const result = await getNoteStructure(index, path33, vaultPath2);
10157
10157
  if (!result) {
10158
10158
  return {
10159
- content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path32 }, null, 2) }]
10159
+ content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path33 }, null, 2) }]
10160
10160
  };
10161
10161
  }
10162
10162
  if (include_content) {
10163
10163
  for (const section of result.sections) {
10164
- const sectionResult = await getSectionContent(index, path32, section.heading.text, vaultPath2, true);
10164
+ const sectionResult = await getSectionContent(index, path33, section.heading.text, vaultPath2, true);
10165
10165
  if (sectionResult) {
10166
10166
  section.content = sectionResult.content;
10167
10167
  }
@@ -10183,15 +10183,15 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
10183
10183
  include_subheadings: z6.boolean().default(true).describe("Include content under subheadings")
10184
10184
  }
10185
10185
  },
10186
- async ({ path: path32, heading, include_subheadings }) => {
10186
+ async ({ path: path33, heading, include_subheadings }) => {
10187
10187
  const index = getIndex();
10188
10188
  const vaultPath2 = getVaultPath();
10189
- const result = await getSectionContent(index, path32, heading, vaultPath2, include_subheadings);
10189
+ const result = await getSectionContent(index, path33, heading, vaultPath2, include_subheadings);
10190
10190
  if (!result) {
10191
10191
  return {
10192
10192
  content: [{ type: "text", text: JSON.stringify({
10193
10193
  error: "Section not found",
10194
- path: path32,
10194
+ path: path33,
10195
10195
  heading
10196
10196
  }, null, 2) }]
10197
10197
  };
@@ -10245,16 +10245,16 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
10245
10245
  offset: z6.coerce.number().default(0).describe("Number of results to skip (for pagination)")
10246
10246
  }
10247
10247
  },
10248
- async ({ path: path32, status, has_due_date, folder, tag, limit: requestedLimit, offset }) => {
10248
+ async ({ path: path33, status, has_due_date, folder, tag, limit: requestedLimit, offset }) => {
10249
10249
  const limit = Math.min(requestedLimit ?? 25, MAX_LIMIT);
10250
10250
  const index = getIndex();
10251
10251
  const vaultPath2 = getVaultPath();
10252
10252
  const config = getConfig();
10253
- if (path32) {
10254
- const result2 = await getTasksFromNote(index, path32, vaultPath2, config.exclude_task_tags || []);
10253
+ if (path33) {
10254
+ const result2 = await getTasksFromNote(index, path33, vaultPath2, config.exclude_task_tags || []);
10255
10255
  if (!result2) {
10256
10256
  return {
10257
- content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path32 }, null, 2) }]
10257
+ content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path33 }, null, 2) }]
10258
10258
  };
10259
10259
  }
10260
10260
  let filtered = result2;
@@ -10264,7 +10264,7 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
10264
10264
  const paged2 = filtered.slice(offset, offset + limit);
10265
10265
  return {
10266
10266
  content: [{ type: "text", text: JSON.stringify({
10267
- path: path32,
10267
+ path: path33,
10268
10268
  total_count: filtered.length,
10269
10269
  returned_count: paged2.length,
10270
10270
  open: result2.filter((t) => t.status === "open").length,
@@ -16040,8 +16040,160 @@ function registerConfigTools(server2, getConfig, setConfig, getStateDb) {
16040
16040
  );
16041
16041
  }
16042
16042
 
16043
- // src/tools/read/metrics.ts
16043
+ // src/tools/write/enrich.ts
16044
16044
  import { z as z23 } from "zod";
16045
+ import * as fs29 from "fs/promises";
16046
+ import * as path30 from "path";
16047
+ function hasSkipWikilinks(content) {
16048
+ if (!content.startsWith("---")) return false;
16049
+ const endIndex = content.indexOf("\n---", 3);
16050
+ if (endIndex === -1) return false;
16051
+ const frontmatter = content.substring(4, endIndex);
16052
+ return /^skipWikilinks:\s*true\s*$/m.test(frontmatter);
16053
+ }
16054
+ async function collectMarkdownFiles(dirPath, basePath, excludeFolders) {
16055
+ const results = [];
16056
+ try {
16057
+ const entries = await fs29.readdir(dirPath, { withFileTypes: true });
16058
+ for (const entry of entries) {
16059
+ if (entry.name.startsWith(".")) continue;
16060
+ const fullPath = path30.join(dirPath, entry.name);
16061
+ if (entry.isDirectory()) {
16062
+ if (excludeFolders.some((f) => entry.name.toLowerCase() === f.toLowerCase())) continue;
16063
+ const sub = await collectMarkdownFiles(fullPath, basePath, excludeFolders);
16064
+ results.push(...sub);
16065
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
16066
+ results.push(path30.relative(basePath, fullPath));
16067
+ }
16068
+ }
16069
+ } catch {
16070
+ }
16071
+ return results;
16072
+ }
16073
+ var EXCLUDE_FOLDERS = [
16074
+ "daily-notes",
16075
+ "daily",
16076
+ "weekly",
16077
+ "weekly-notes",
16078
+ "monthly",
16079
+ "monthly-notes",
16080
+ "quarterly",
16081
+ "yearly-notes",
16082
+ "periodic",
16083
+ "journal",
16084
+ "inbox",
16085
+ "templates",
16086
+ "attachments",
16087
+ "tmp",
16088
+ "clippings",
16089
+ "readwise",
16090
+ "articles",
16091
+ "bookmarks",
16092
+ "web-clips"
16093
+ ];
16094
+ function registerInitTools(server2, vaultPath2, getStateDb) {
16095
+ server2.tool(
16096
+ "vault_init",
16097
+ "Initialize vault for Flywheel \u2014 scans legacy notes with zero wikilinks and applies entity links. Safe to re-run (idempotent). Use dry_run (default) to preview.",
16098
+ {
16099
+ dry_run: z23.boolean().default(true).describe("If true (default), preview what would be linked without modifying files"),
16100
+ batch_size: z23.number().default(50).describe("Maximum notes to process per invocation (default: 50)"),
16101
+ offset: z23.number().default(0).describe("Skip this many eligible notes (for pagination across invocations)")
16102
+ },
16103
+ async ({ dry_run, batch_size, offset }) => {
16104
+ const startTime = Date.now();
16105
+ const stateDb2 = getStateDb();
16106
+ const lastRunRow = stateDb2?.getMetadataValue.get("vault_init_last_run_at");
16107
+ const totalEnrichedRow = stateDb2?.getMetadataValue.get("vault_init_total_enriched");
16108
+ const previousTotal = totalEnrichedRow ? parseInt(totalEnrichedRow.value, 10) : 0;
16109
+ checkAndRefreshIfStale();
16110
+ if (!isEntityIndexReady()) {
16111
+ const result = {
16112
+ success: false,
16113
+ mode: dry_run ? "dry_run" : "apply",
16114
+ notes_scanned: 0,
16115
+ notes_with_matches: 0,
16116
+ notes_skipped: 0,
16117
+ total_matches: 0,
16118
+ preview: [],
16119
+ duration_ms: Date.now() - startTime,
16120
+ last_run_at: lastRunRow?.value ?? null,
16121
+ total_enriched: previousTotal
16122
+ };
16123
+ return { content: [{ type: "text", text: JSON.stringify({ ...result, error: "Entity index not ready" }, null, 2) }] };
16124
+ }
16125
+ const allFiles = await collectMarkdownFiles(vaultPath2, vaultPath2, EXCLUDE_FOLDERS);
16126
+ const eligible = [];
16127
+ let notesSkipped = 0;
16128
+ for (const relativePath of allFiles) {
16129
+ const fullPath = path30.join(vaultPath2, relativePath);
16130
+ let content;
16131
+ try {
16132
+ content = await fs29.readFile(fullPath, "utf-8");
16133
+ } catch {
16134
+ continue;
16135
+ }
16136
+ if (hasSkipWikilinks(content)) {
16137
+ notesSkipped++;
16138
+ continue;
16139
+ }
16140
+ const existingLinks = extractLinkedEntities(content);
16141
+ if (existingLinks.size > 0) continue;
16142
+ eligible.push({ relativePath, content });
16143
+ }
16144
+ const paged = eligible.slice(offset, offset + batch_size);
16145
+ const preview = [];
16146
+ let totalMatches = 0;
16147
+ let notesModified = 0;
16148
+ for (const { relativePath, content } of paged) {
16149
+ const result = processWikilinks(content, relativePath);
16150
+ if (result.linksAdded === 0) continue;
16151
+ const entities = result.linkedEntities;
16152
+ totalMatches += result.linksAdded;
16153
+ preview.push({
16154
+ note: relativePath,
16155
+ entities,
16156
+ match_count: result.linksAdded
16157
+ });
16158
+ if (!dry_run) {
16159
+ const fullPath = path30.join(vaultPath2, relativePath);
16160
+ await fs29.writeFile(fullPath, result.content, "utf-8");
16161
+ notesModified++;
16162
+ if (stateDb2) {
16163
+ trackWikilinkApplications(stateDb2, relativePath, entities);
16164
+ const newLinks = extractLinkedEntities(result.content);
16165
+ updateStoredNoteLinks(stateDb2, relativePath, newLinks);
16166
+ }
16167
+ }
16168
+ }
16169
+ if (!dry_run && stateDb2 && notesModified > 0) {
16170
+ const newTotal = previousTotal + notesModified;
16171
+ stateDb2.setMetadataValue.run("vault_init_last_run_at", (/* @__PURE__ */ new Date()).toISOString());
16172
+ stateDb2.setMetadataValue.run("vault_init_total_enriched", String(newTotal));
16173
+ }
16174
+ const currentLastRun = !dry_run && notesModified > 0 ? (/* @__PURE__ */ new Date()).toISOString() : lastRunRow?.value ?? null;
16175
+ const currentTotal = !dry_run ? previousTotal + notesModified : previousTotal;
16176
+ const output = {
16177
+ success: true,
16178
+ mode: dry_run ? "dry_run" : "apply",
16179
+ notes_scanned: allFiles.length,
16180
+ notes_with_matches: preview.length,
16181
+ notes_skipped: notesSkipped,
16182
+ total_matches: totalMatches,
16183
+ ...dry_run ? {} : { notes_modified: notesModified },
16184
+ preview: preview.slice(0, 20),
16185
+ // Cap preview to 20 items in output
16186
+ duration_ms: Date.now() - startTime,
16187
+ last_run_at: currentLastRun,
16188
+ total_enriched: currentTotal
16189
+ };
16190
+ return { content: [{ type: "text", text: JSON.stringify(output, null, 2) }] };
16191
+ }
16192
+ );
16193
+ }
16194
+
16195
+ // src/tools/read/metrics.ts
16196
+ import { z as z24 } from "zod";
16045
16197
 
16046
16198
  // src/core/shared/metrics.ts
16047
16199
  var ALL_METRICS = [
@@ -16207,10 +16359,10 @@ function registerMetricsTools(server2, getIndex, getStateDb) {
16207
16359
  title: "Vault Growth",
16208
16360
  description: 'Track vault growth over time. Modes: "current" (live snapshot), "history" (time series), "trends" (deltas vs N days ago), "index_activity" (rebuild history). Tracks 11 metrics: note_count, link_count, orphan_count, tag_count, entity_count, avg_links_per_note, link_density, connected_ratio, wikilink_accuracy, wikilink_feedback_volume, wikilink_suppressed_count.',
16209
16361
  inputSchema: {
16210
- mode: z23.enum(["current", "history", "trends", "index_activity"]).describe("Query mode: current snapshot, historical time series, trend analysis, or index rebuild activity"),
16211
- metric: z23.string().optional().describe('Filter to specific metric (e.g., "note_count"). Omit for all metrics.'),
16212
- days_back: z23.number().optional().describe("Number of days to look back for history/trends (default: 30)"),
16213
- limit: z23.number().optional().describe("Number of recent events to return for index_activity mode (default: 20)")
16362
+ mode: z24.enum(["current", "history", "trends", "index_activity"]).describe("Query mode: current snapshot, historical time series, trend analysis, or index rebuild activity"),
16363
+ metric: z24.string().optional().describe('Filter to specific metric (e.g., "note_count"). Omit for all metrics.'),
16364
+ days_back: z24.number().optional().describe("Number of days to look back for history/trends (default: 30)"),
16365
+ limit: z24.number().optional().describe("Number of recent events to return for index_activity mode (default: 20)")
16214
16366
  }
16215
16367
  },
16216
16368
  async ({ mode, metric, days_back, limit: eventLimit }) => {
@@ -16283,7 +16435,7 @@ function registerMetricsTools(server2, getIndex, getStateDb) {
16283
16435
  }
16284
16436
 
16285
16437
  // src/tools/read/activity.ts
16286
- import { z as z24 } from "zod";
16438
+ import { z as z25 } from "zod";
16287
16439
 
16288
16440
  // src/core/shared/toolTracking.ts
16289
16441
  function recordToolInvocation(stateDb2, event) {
@@ -16363,8 +16515,8 @@ function getNoteAccessFrequency(stateDb2, daysBack = 30) {
16363
16515
  }
16364
16516
  }
16365
16517
  }
16366
- return Array.from(noteMap.entries()).map(([path32, stats]) => ({
16367
- path: path32,
16518
+ return Array.from(noteMap.entries()).map(([path33, stats]) => ({
16519
+ path: path33,
16368
16520
  access_count: stats.access_count,
16369
16521
  last_accessed: stats.last_accessed,
16370
16522
  tools_used: Array.from(stats.tools)
@@ -16443,10 +16595,10 @@ function registerActivityTools(server2, getStateDb, getSessionId2) {
16443
16595
  title: "Vault Activity",
16444
16596
  description: 'Track tool usage patterns and session activity. Modes:\n- "session": Current session summary (tools called, notes accessed)\n- "sessions": List of recent sessions\n- "note_access": Notes ranked by query frequency\n- "tool_usage": Tool usage patterns (most-used tools, avg duration)',
16445
16597
  inputSchema: {
16446
- mode: z24.enum(["session", "sessions", "note_access", "tool_usage"]).describe("Activity query mode"),
16447
- session_id: z24.string().optional().describe("Specific session ID (for session mode, defaults to current)"),
16448
- days_back: z24.number().optional().describe("Number of days to look back (default: 30)"),
16449
- limit: z24.number().optional().describe("Maximum results to return (default: 20)")
16598
+ mode: z25.enum(["session", "sessions", "note_access", "tool_usage"]).describe("Activity query mode"),
16599
+ session_id: z25.string().optional().describe("Specific session ID (for session mode, defaults to current)"),
16600
+ days_back: z25.number().optional().describe("Number of days to look back (default: 30)"),
16601
+ limit: z25.number().optional().describe("Maximum results to return (default: 20)")
16450
16602
  }
16451
16603
  },
16452
16604
  async ({ mode, session_id, days_back, limit: resultLimit }) => {
@@ -16513,11 +16665,11 @@ function registerActivityTools(server2, getStateDb, getSessionId2) {
16513
16665
  }
16514
16666
 
16515
16667
  // src/tools/read/similarity.ts
16516
- import { z as z25 } from "zod";
16668
+ import { z as z26 } from "zod";
16517
16669
 
16518
16670
  // src/core/read/similarity.ts
16519
- import * as fs29 from "fs";
16520
- import * as path30 from "path";
16671
+ import * as fs30 from "fs";
16672
+ import * as path31 from "path";
16521
16673
  var STOP_WORDS = /* @__PURE__ */ new Set([
16522
16674
  "the",
16523
16675
  "be",
@@ -16654,10 +16806,10 @@ function extractKeyTerms(content, maxTerms = 15) {
16654
16806
  }
16655
16807
  function findSimilarNotes(db4, vaultPath2, index, sourcePath, options = {}) {
16656
16808
  const limit = options.limit ?? 10;
16657
- const absPath = path30.join(vaultPath2, sourcePath);
16809
+ const absPath = path31.join(vaultPath2, sourcePath);
16658
16810
  let content;
16659
16811
  try {
16660
- content = fs29.readFileSync(absPath, "utf-8");
16812
+ content = fs30.readFileSync(absPath, "utf-8");
16661
16813
  } catch {
16662
16814
  return [];
16663
16815
  }
@@ -16777,12 +16929,12 @@ function registerSimilarityTools(server2, getIndex, getVaultPath, getStateDb) {
16777
16929
  title: "Find Similar Notes",
16778
16930
  description: "Find notes similar to a given note using FTS5 keyword matching. When embeddings have been built (via init_semantic), automatically uses hybrid ranking (BM25 + embedding similarity via Reciprocal Rank Fusion). Use exclude_linked to filter out notes already connected via wikilinks.",
16779
16931
  inputSchema: {
16780
- path: z25.string().describe('Path to the source note (relative to vault root, e.g. "projects/alpha.md")'),
16781
- limit: z25.number().optional().describe("Maximum number of similar notes to return (default: 10)"),
16782
- exclude_linked: z25.boolean().optional().describe("Exclude notes already linked to/from the source note (default: true)")
16932
+ path: z26.string().describe('Path to the source note (relative to vault root, e.g. "projects/alpha.md")'),
16933
+ limit: z26.number().optional().describe("Maximum number of similar notes to return (default: 10)"),
16934
+ exclude_linked: z26.boolean().optional().describe("Exclude notes already linked to/from the source note (default: true)")
16783
16935
  }
16784
16936
  },
16785
- async ({ path: path32, limit, exclude_linked }) => {
16937
+ async ({ path: path33, limit, exclude_linked }) => {
16786
16938
  const index = getIndex();
16787
16939
  const vaultPath2 = getVaultPath();
16788
16940
  const stateDb2 = getStateDb();
@@ -16791,10 +16943,10 @@ function registerSimilarityTools(server2, getIndex, getVaultPath, getStateDb) {
16791
16943
  content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available" }) }]
16792
16944
  };
16793
16945
  }
16794
- if (!index.notes.has(path32)) {
16946
+ if (!index.notes.has(path33)) {
16795
16947
  return {
16796
16948
  content: [{ type: "text", text: JSON.stringify({
16797
- error: `Note not found: ${path32}`,
16949
+ error: `Note not found: ${path33}`,
16798
16950
  hint: "Use the full relative path including .md extension"
16799
16951
  }, null, 2) }]
16800
16952
  };
@@ -16805,12 +16957,12 @@ function registerSimilarityTools(server2, getIndex, getVaultPath, getStateDb) {
16805
16957
  };
16806
16958
  const useHybrid = hasEmbeddingsIndex();
16807
16959
  const method = useHybrid ? "hybrid" : "bm25";
16808
- const results = useHybrid ? await findHybridSimilarNotes(stateDb2.db, vaultPath2, index, path32, opts) : findSimilarNotes(stateDb2.db, vaultPath2, index, path32, opts);
16960
+ const results = useHybrid ? await findHybridSimilarNotes(stateDb2.db, vaultPath2, index, path33, opts) : findSimilarNotes(stateDb2.db, vaultPath2, index, path33, opts);
16809
16961
  return {
16810
16962
  content: [{
16811
16963
  type: "text",
16812
16964
  text: JSON.stringify({
16813
- source: path32,
16965
+ source: path33,
16814
16966
  method,
16815
16967
  exclude_linked: exclude_linked ?? true,
16816
16968
  count: results.length,
@@ -16823,7 +16975,7 @@ function registerSimilarityTools(server2, getIndex, getVaultPath, getStateDb) {
16823
16975
  }
16824
16976
 
16825
16977
  // src/tools/read/semantic.ts
16826
- import { z as z26 } from "zod";
16978
+ import { z as z27 } from "zod";
16827
16979
  import { getAllEntitiesFromDb } from "@velvetmonkey/vault-core";
16828
16980
  function registerSemanticTools(server2, getVaultPath, getStateDb) {
16829
16981
  server2.registerTool(
@@ -16832,7 +16984,7 @@ function registerSemanticTools(server2, getVaultPath, getStateDb) {
16832
16984
  title: "Initialize Semantic Search",
16833
16985
  description: "Download the embedding model and build semantic search index for this vault. After running, search and find_similar automatically use hybrid ranking (BM25 + semantic). Run once per vault \u2014 subsequent calls skip already-embedded notes unless force=true.",
16834
16986
  inputSchema: {
16835
- force: z26.boolean().optional().describe(
16987
+ force: z27.boolean().optional().describe(
16836
16988
  "Rebuild all embeddings even if they already exist (default: false)"
16837
16989
  )
16838
16990
  }
@@ -16912,7 +17064,7 @@ function registerSemanticTools(server2, getVaultPath, getStateDb) {
16912
17064
 
16913
17065
  // src/tools/read/merges.ts
16914
17066
  init_levenshtein();
16915
- import { z as z27 } from "zod";
17067
+ import { z as z28 } from "zod";
16916
17068
  import { getAllEntitiesFromDb as getAllEntitiesFromDb2, getDismissedMergePairs, recordMergeDismissal } from "@velvetmonkey/vault-core";
16917
17069
  function normalizeName(name) {
16918
17070
  return name.toLowerCase().replace(/[.\-_]/g, "").replace(/js$/, "").replace(/ts$/, "");
@@ -16922,7 +17074,7 @@ function registerMergeTools2(server2, getStateDb) {
16922
17074
  "suggest_entity_merges",
16923
17075
  "Find potential duplicate entities that could be merged based on name similarity",
16924
17076
  {
16925
- limit: z27.number().optional().default(50).describe("Maximum number of suggestions to return")
17077
+ limit: z28.number().optional().default(50).describe("Maximum number of suggestions to return")
16926
17078
  },
16927
17079
  async ({ limit }) => {
16928
17080
  const stateDb2 = getStateDb();
@@ -17024,11 +17176,11 @@ function registerMergeTools2(server2, getStateDb) {
17024
17176
  "dismiss_merge_suggestion",
17025
17177
  "Permanently dismiss a merge suggestion so it never reappears",
17026
17178
  {
17027
- source_path: z27.string().describe("Path of the source entity"),
17028
- target_path: z27.string().describe("Path of the target entity"),
17029
- source_name: z27.string().describe("Name of the source entity"),
17030
- target_name: z27.string().describe("Name of the target entity"),
17031
- reason: z27.string().describe("Original suggestion reason")
17179
+ source_path: z28.string().describe("Path of the source entity"),
17180
+ target_path: z28.string().describe("Path of the target entity"),
17181
+ source_name: z28.string().describe("Name of the source entity"),
17182
+ target_name: z28.string().describe("Name of the target entity"),
17183
+ reason: z28.string().describe("Original suggestion reason")
17032
17184
  },
17033
17185
  async ({ source_path, target_path, source_name, target_name, reason }) => {
17034
17186
  const stateDb2 = getStateDb();
@@ -17047,7 +17199,7 @@ function registerMergeTools2(server2, getStateDb) {
17047
17199
  }
17048
17200
 
17049
17201
  // src/index.ts
17050
- import * as fs30 from "node:fs/promises";
17202
+ import * as fs31 from "node:fs/promises";
17051
17203
  import { createHash as createHash2 } from "node:crypto";
17052
17204
 
17053
17205
  // src/resources/vault.ts
@@ -17156,7 +17308,7 @@ function registerVaultResources(server2, getIndex) {
17156
17308
  // src/index.ts
17157
17309
  var __filename = fileURLToPath(import.meta.url);
17158
17310
  var __dirname = dirname4(__filename);
17159
- var pkg = JSON.parse(readFileSync4(join16(__dirname, "../package.json"), "utf-8"));
17311
+ var pkg = JSON.parse(readFileSync4(join17(__dirname, "../package.json"), "utf-8"));
17160
17312
  var vaultPath = process.env.PROJECT_PATH || process.env.VAULT_PATH || findVaultRoot();
17161
17313
  var resolvedVaultPath;
17162
17314
  try {
@@ -17423,6 +17575,7 @@ registerSystemTools2(server, vaultPath);
17423
17575
  registerPolicyTools(server, vaultPath);
17424
17576
  registerTagTools(server, () => vaultIndex, () => vaultPath);
17425
17577
  registerWikilinkFeedbackTools(server, () => stateDb);
17578
+ registerInitTools(server, vaultPath, () => stateDb);
17426
17579
  registerConfigTools(
17427
17580
  server,
17428
17581
  () => flywheelConfig,
@@ -17460,6 +17613,10 @@ async function main() {
17460
17613
  setWriteStateDb(stateDb);
17461
17614
  setRecencyStateDb(stateDb);
17462
17615
  setEdgeWeightStateDb(stateDb);
17616
+ const vaultInitRow = stateDb.getMetadataValue.get("vault_init_last_run_at");
17617
+ if (!vaultInitRow) {
17618
+ serverLog("server", "Vault not initialized \u2014 call vault_init to enrich legacy notes");
17619
+ }
17463
17620
  } catch (err) {
17464
17621
  const msg = err instanceof Error ? err.message : String(err);
17465
17622
  serverLog("statedb", `StateDb initialization failed: ${msg}`, "error");
@@ -17577,22 +17734,22 @@ async function buildStartupCatchupBatch(vaultPath2, sinceMs) {
17577
17734
  async function scanDir(dir) {
17578
17735
  let entries;
17579
17736
  try {
17580
- entries = await fs30.readdir(dir, { withFileTypes: true });
17737
+ entries = await fs31.readdir(dir, { withFileTypes: true });
17581
17738
  } catch {
17582
17739
  return;
17583
17740
  }
17584
17741
  for (const entry of entries) {
17585
- const fullPath = path31.join(dir, entry.name);
17742
+ const fullPath = path32.join(dir, entry.name);
17586
17743
  if (entry.isDirectory()) {
17587
17744
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
17588
17745
  await scanDir(fullPath);
17589
17746
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
17590
17747
  try {
17591
- const stat4 = await fs30.stat(fullPath);
17748
+ const stat4 = await fs31.stat(fullPath);
17592
17749
  if (stat4.mtimeMs > sinceMs) {
17593
17750
  events.push({
17594
17751
  type: "upsert",
17595
- path: path31.relative(vaultPath2, fullPath),
17752
+ path: path32.relative(vaultPath2, fullPath),
17596
17753
  originalEvents: []
17597
17754
  });
17598
17755
  }
@@ -17717,8 +17874,8 @@ async function runPostIndexWork(index) {
17717
17874
  }
17718
17875
  } catch {
17719
17876
  try {
17720
- const dir = path31.dirname(rawPath);
17721
- const base = path31.basename(rawPath);
17877
+ const dir = path32.dirname(rawPath);
17878
+ const base = path32.basename(rawPath);
17722
17879
  const resolvedDir = realpathSync(dir).replace(/\\/g, "/");
17723
17880
  for (const prefix of vaultPrefixes) {
17724
17881
  if (resolvedDir.startsWith(prefix + "/") || resolvedDir === prefix) {
@@ -17747,7 +17904,7 @@ async function runPostIndexWork(index) {
17747
17904
  continue;
17748
17905
  }
17749
17906
  try {
17750
- const content = await fs30.readFile(path31.join(vaultPath, event.path), "utf-8");
17907
+ const content = await fs31.readFile(path32.join(vaultPath, event.path), "utf-8");
17751
17908
  const hash = createHash2("md5").update(content).digest("hex");
17752
17909
  if (lastContentHashes.get(event.path) === hash) {
17753
17910
  serverLog("watcher", `Hash unchanged, skipping: ${event.path}`);
@@ -17818,7 +17975,7 @@ async function runPostIndexWork(index) {
17818
17975
  ...batch,
17819
17976
  events: filteredEvents.map((e) => ({
17820
17977
  ...e,
17821
- path: path31.join(vaultPath, e.path)
17978
+ path: path32.join(vaultPath, e.path)
17822
17979
  }))
17823
17980
  };
17824
17981
  const batchResult = await processBatch(vaultIndex, vaultPath, absoluteBatch);
@@ -17952,7 +18109,7 @@ async function runPostIndexWork(index) {
17952
18109
  removeEmbedding(event.path);
17953
18110
  embRemoved++;
17954
18111
  } else if (event.path.endsWith(".md")) {
17955
- const absPath = path31.join(vaultPath, event.path);
18112
+ const absPath = path32.join(vaultPath, event.path);
17956
18113
  await updateEmbedding(event.path, absPath);
17957
18114
  embUpdated++;
17958
18115
  }
@@ -18159,7 +18316,7 @@ async function runPostIndexWork(index) {
18159
18316
  for (const event of filteredEvents) {
18160
18317
  if (event.type === "delete" || !event.path.endsWith(".md")) continue;
18161
18318
  try {
18162
- const content = await fs30.readFile(path31.join(vaultPath, event.path), "utf-8");
18319
+ const content = await fs31.readFile(path32.join(vaultPath, event.path), "utf-8");
18163
18320
  const zones = getProtectedZones2(content);
18164
18321
  const linked = new Set(
18165
18322
  (forwardLinkResults.find((r) => r.file === event.path)?.resolved ?? []).map((n) => n.toLowerCase())
@@ -18195,7 +18352,7 @@ async function runPostIndexWork(index) {
18195
18352
  for (const event of filteredEvents) {
18196
18353
  if (event.type === "delete" || !event.path.endsWith(".md")) continue;
18197
18354
  try {
18198
- const content = await fs30.readFile(path31.join(vaultPath, event.path), "utf-8");
18355
+ const content = await fs31.readFile(path32.join(vaultPath, event.path), "utf-8");
18199
18356
  const removed = processImplicitFeedback(stateDb, event.path, content);
18200
18357
  for (const entity of removed) feedbackResults.push({ entity, file: event.path });
18201
18358
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.0.44",
3
+ "version": "2.0.45",
4
4
  "description": "MCP server that gives Claude full read/write access to your Obsidian vault. Select from 42 tools for search, backlinks, graph queries, mutations, and hybrid semantic search.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",