@velvetmonkey/flywheel-memory 2.0.10 → 2.0.11

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 +718 -63
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -711,8 +711,8 @@ function createContext(variables = {}) {
711
711
  }
712
712
  };
713
713
  }
714
- function resolvePath(obj, path24) {
715
- const parts = path24.split(".");
714
+ function resolvePath(obj, path25) {
715
+ const parts = path25.split(".");
716
716
  let current = obj;
717
717
  for (const part of parts) {
718
718
  if (current === void 0 || current === null) {
@@ -1690,8 +1690,8 @@ function updateIndexProgress(parsed, total) {
1690
1690
  function normalizeTarget(target) {
1691
1691
  return target.toLowerCase().replace(/\.md$/, "");
1692
1692
  }
1693
- function normalizeNotePath(path24) {
1694
- return path24.toLowerCase().replace(/\.md$/, "");
1693
+ function normalizeNotePath(path25) {
1694
+ return path25.toLowerCase().replace(/\.md$/, "");
1695
1695
  }
1696
1696
  async function buildVaultIndex(vaultPath2, options = {}) {
1697
1697
  const { timeoutMs = DEFAULT_TIMEOUT_MS, onProgress } = options;
@@ -1885,7 +1885,7 @@ function findSimilarEntity(index, target) {
1885
1885
  }
1886
1886
  const maxDist = normalizedLen <= 10 ? 1 : 2;
1887
1887
  let bestMatch;
1888
- for (const [entity, path24] of index.entities) {
1888
+ for (const [entity, path25] of index.entities) {
1889
1889
  const lenDiff = Math.abs(entity.length - normalizedLen);
1890
1890
  if (lenDiff > maxDist) {
1891
1891
  continue;
@@ -1893,7 +1893,7 @@ function findSimilarEntity(index, target) {
1893
1893
  const dist = levenshteinDistance(normalized, entity);
1894
1894
  if (dist > 0 && dist <= maxDist) {
1895
1895
  if (!bestMatch || dist < bestMatch.distance) {
1896
- bestMatch = { path: path24, entity, distance: dist };
1896
+ bestMatch = { path: path25, entity, distance: dist };
1897
1897
  if (dist === 1) {
1898
1898
  return bestMatch;
1899
1899
  }
@@ -2355,30 +2355,30 @@ var EventQueue = class {
2355
2355
  * Add a new event to the queue
2356
2356
  */
2357
2357
  push(type, rawPath) {
2358
- const path24 = normalizePath(rawPath);
2358
+ const path25 = normalizePath(rawPath);
2359
2359
  const now = Date.now();
2360
2360
  const event = {
2361
2361
  type,
2362
- path: path24,
2362
+ path: path25,
2363
2363
  timestamp: now
2364
2364
  };
2365
- let pending = this.pending.get(path24);
2365
+ let pending = this.pending.get(path25);
2366
2366
  if (!pending) {
2367
2367
  pending = {
2368
2368
  events: [],
2369
2369
  timer: null,
2370
2370
  lastEvent: now
2371
2371
  };
2372
- this.pending.set(path24, pending);
2372
+ this.pending.set(path25, pending);
2373
2373
  }
2374
2374
  pending.events.push(event);
2375
2375
  pending.lastEvent = now;
2376
- console.error(`[flywheel] QUEUE: pushed ${type} for ${path24}, pending=${this.pending.size}`);
2376
+ console.error(`[flywheel] QUEUE: pushed ${type} for ${path25}, pending=${this.pending.size}`);
2377
2377
  if (pending.timer) {
2378
2378
  clearTimeout(pending.timer);
2379
2379
  }
2380
2380
  pending.timer = setTimeout(() => {
2381
- this.flushPath(path24);
2381
+ this.flushPath(path25);
2382
2382
  }, this.config.debounceMs);
2383
2383
  if (this.pending.size >= this.config.batchSize) {
2384
2384
  this.flush();
@@ -2399,10 +2399,10 @@ var EventQueue = class {
2399
2399
  /**
2400
2400
  * Flush a single path's events
2401
2401
  */
2402
- flushPath(path24) {
2403
- const pending = this.pending.get(path24);
2402
+ flushPath(path25) {
2403
+ const pending = this.pending.get(path25);
2404
2404
  if (!pending || pending.events.length === 0) return;
2405
- console.error(`[flywheel] QUEUE: flushing ${path24}, events=${pending.events.length}`);
2405
+ console.error(`[flywheel] QUEUE: flushing ${path25}, events=${pending.events.length}`);
2406
2406
  if (pending.timer) {
2407
2407
  clearTimeout(pending.timer);
2408
2408
  pending.timer = null;
@@ -2411,7 +2411,7 @@ var EventQueue = class {
2411
2411
  if (coalescedType) {
2412
2412
  const coalesced = {
2413
2413
  type: coalescedType,
2414
- path: path24,
2414
+ path: path25,
2415
2415
  originalEvents: [...pending.events]
2416
2416
  };
2417
2417
  this.onBatch({
@@ -2419,7 +2419,7 @@ var EventQueue = class {
2419
2419
  timestamp: Date.now()
2420
2420
  });
2421
2421
  }
2422
- this.pending.delete(path24);
2422
+ this.pending.delete(path25);
2423
2423
  }
2424
2424
  /**
2425
2425
  * Flush all pending events
@@ -2431,7 +2431,7 @@ var EventQueue = class {
2431
2431
  }
2432
2432
  if (this.pending.size === 0) return;
2433
2433
  const events = [];
2434
- for (const [path24, pending] of this.pending) {
2434
+ for (const [path25, pending] of this.pending) {
2435
2435
  if (pending.timer) {
2436
2436
  clearTimeout(pending.timer);
2437
2437
  }
@@ -2439,7 +2439,7 @@ var EventQueue = class {
2439
2439
  if (coalescedType) {
2440
2440
  events.push({
2441
2441
  type: coalescedType,
2442
- path: path24,
2442
+ path: path25,
2443
2443
  originalEvents: [...pending.events]
2444
2444
  });
2445
2445
  }
@@ -2588,31 +2588,31 @@ function createVaultWatcher(options) {
2588
2588
  usePolling: config.usePolling,
2589
2589
  interval: config.usePolling ? config.pollInterval : void 0
2590
2590
  });
2591
- watcher.on("add", (path24) => {
2592
- console.error(`[flywheel] RAW EVENT: add ${path24}`);
2593
- if (shouldWatch(path24, vaultPath2)) {
2594
- console.error(`[flywheel] ACCEPTED: add ${path24}`);
2595
- eventQueue.push("add", path24);
2591
+ watcher.on("add", (path25) => {
2592
+ console.error(`[flywheel] RAW EVENT: add ${path25}`);
2593
+ if (shouldWatch(path25, vaultPath2)) {
2594
+ console.error(`[flywheel] ACCEPTED: add ${path25}`);
2595
+ eventQueue.push("add", path25);
2596
2596
  } else {
2597
- console.error(`[flywheel] FILTERED: add ${path24}`);
2597
+ console.error(`[flywheel] FILTERED: add ${path25}`);
2598
2598
  }
2599
2599
  });
2600
- watcher.on("change", (path24) => {
2601
- console.error(`[flywheel] RAW EVENT: change ${path24}`);
2602
- if (shouldWatch(path24, vaultPath2)) {
2603
- console.error(`[flywheel] ACCEPTED: change ${path24}`);
2604
- eventQueue.push("change", path24);
2600
+ watcher.on("change", (path25) => {
2601
+ console.error(`[flywheel] RAW EVENT: change ${path25}`);
2602
+ if (shouldWatch(path25, vaultPath2)) {
2603
+ console.error(`[flywheel] ACCEPTED: change ${path25}`);
2604
+ eventQueue.push("change", path25);
2605
2605
  } else {
2606
- console.error(`[flywheel] FILTERED: change ${path24}`);
2606
+ console.error(`[flywheel] FILTERED: change ${path25}`);
2607
2607
  }
2608
2608
  });
2609
- watcher.on("unlink", (path24) => {
2610
- console.error(`[flywheel] RAW EVENT: unlink ${path24}`);
2611
- if (shouldWatch(path24, vaultPath2)) {
2612
- console.error(`[flywheel] ACCEPTED: unlink ${path24}`);
2613
- eventQueue.push("unlink", path24);
2609
+ watcher.on("unlink", (path25) => {
2610
+ console.error(`[flywheel] RAW EVENT: unlink ${path25}`);
2611
+ if (shouldWatch(path25, vaultPath2)) {
2612
+ console.error(`[flywheel] ACCEPTED: unlink ${path25}`);
2613
+ eventQueue.push("unlink", path25);
2614
2614
  } else {
2615
- console.error(`[flywheel] FILTERED: unlink ${path24}`);
2615
+ console.error(`[flywheel] FILTERED: unlink ${path25}`);
2616
2616
  }
2617
2617
  });
2618
2618
  watcher.on("ready", () => {
@@ -2728,6 +2728,105 @@ import {
2728
2728
  searchEntities as searchEntitiesDb
2729
2729
  } from "@velvetmonkey/vault-core";
2730
2730
 
2731
+ // src/core/write/wikilinkFeedback.ts
2732
+ var MIN_FEEDBACK_COUNT = 10;
2733
+ var SUPPRESSION_THRESHOLD = 0.3;
2734
+ function recordFeedback(stateDb2, entity, context, notePath, correct) {
2735
+ stateDb2.db.prepare(
2736
+ "INSERT INTO wikilink_feedback (entity, context, note_path, correct) VALUES (?, ?, ?, ?)"
2737
+ ).run(entity, context, notePath, correct ? 1 : 0);
2738
+ }
2739
+ function getFeedback(stateDb2, entity, limit = 20) {
2740
+ let rows;
2741
+ if (entity) {
2742
+ rows = stateDb2.db.prepare(
2743
+ "SELECT id, entity, context, note_path, correct, created_at FROM wikilink_feedback WHERE entity = ? ORDER BY created_at DESC LIMIT ?"
2744
+ ).all(entity, limit);
2745
+ } else {
2746
+ rows = stateDb2.db.prepare(
2747
+ "SELECT id, entity, context, note_path, correct, created_at FROM wikilink_feedback ORDER BY created_at DESC LIMIT ?"
2748
+ ).all(limit);
2749
+ }
2750
+ return rows.map((r) => ({
2751
+ id: r.id,
2752
+ entity: r.entity,
2753
+ context: r.context,
2754
+ note_path: r.note_path,
2755
+ correct: r.correct === 1,
2756
+ created_at: r.created_at
2757
+ }));
2758
+ }
2759
+ function getEntityStats(stateDb2) {
2760
+ const rows = stateDb2.db.prepare(`
2761
+ SELECT
2762
+ entity,
2763
+ COUNT(*) as total,
2764
+ SUM(CASE WHEN correct = 1 THEN 1 ELSE 0 END) as correct_count,
2765
+ SUM(CASE WHEN correct = 0 THEN 1 ELSE 0 END) as incorrect_count
2766
+ FROM wikilink_feedback
2767
+ GROUP BY entity
2768
+ ORDER BY total DESC
2769
+ `).all();
2770
+ return rows.map((r) => {
2771
+ const suppressed = isSuppressed(stateDb2, r.entity);
2772
+ return {
2773
+ entity: r.entity,
2774
+ total: r.total,
2775
+ correct: r.correct_count,
2776
+ incorrect: r.incorrect_count,
2777
+ accuracy: r.total > 0 ? Math.round(r.correct_count / r.total * 1e3) / 1e3 : 0,
2778
+ suppressed
2779
+ };
2780
+ });
2781
+ }
2782
+ function updateSuppressionList(stateDb2) {
2783
+ const stats = stateDb2.db.prepare(`
2784
+ SELECT
2785
+ entity,
2786
+ COUNT(*) as total,
2787
+ SUM(CASE WHEN correct = 0 THEN 1 ELSE 0 END) as false_positives
2788
+ FROM wikilink_feedback
2789
+ GROUP BY entity
2790
+ HAVING total >= ?
2791
+ `).all(MIN_FEEDBACK_COUNT);
2792
+ let updated = 0;
2793
+ const upsert = stateDb2.db.prepare(`
2794
+ INSERT INTO wikilink_suppressions (entity, false_positive_rate, updated_at)
2795
+ VALUES (?, ?, datetime('now'))
2796
+ ON CONFLICT(entity) DO UPDATE SET
2797
+ false_positive_rate = excluded.false_positive_rate,
2798
+ updated_at = datetime('now')
2799
+ `);
2800
+ const remove = stateDb2.db.prepare(
2801
+ "DELETE FROM wikilink_suppressions WHERE entity = ?"
2802
+ );
2803
+ const transaction = stateDb2.db.transaction(() => {
2804
+ for (const stat3 of stats) {
2805
+ const fpRate = stat3.false_positives / stat3.total;
2806
+ if (fpRate >= SUPPRESSION_THRESHOLD) {
2807
+ upsert.run(stat3.entity, fpRate);
2808
+ updated++;
2809
+ } else {
2810
+ remove.run(stat3.entity);
2811
+ }
2812
+ }
2813
+ });
2814
+ transaction();
2815
+ return updated;
2816
+ }
2817
+ function isSuppressed(stateDb2, entity) {
2818
+ const row = stateDb2.db.prepare(
2819
+ "SELECT entity FROM wikilink_suppressions WHERE entity = ?"
2820
+ ).get(entity);
2821
+ return !!row;
2822
+ }
2823
+ function getSuppressedCount(stateDb2) {
2824
+ const row = stateDb2.db.prepare(
2825
+ "SELECT COUNT(*) as count FROM wikilink_suppressions"
2826
+ ).get();
2827
+ return row.count;
2828
+ }
2829
+
2731
2830
  // src/core/write/git.ts
2732
2831
  import { simpleGit, CheckRepoActions } from "simple-git";
2733
2832
  import path5 from "path";
@@ -4218,8 +4317,14 @@ function processWikilinks(content, notePath) {
4218
4317
  linkedEntities: []
4219
4318
  };
4220
4319
  }
4221
- const entities = getAllEntities(entityIndex);
4320
+ let entities = getAllEntities(entityIndex);
4222
4321
  console.error(`[Flywheel:DEBUG] Processing wikilinks with ${entities.length} entities`);
4322
+ if (moduleStateDb4) {
4323
+ entities = entities.filter((e) => {
4324
+ const name = getEntityName2(e);
4325
+ return !isSuppressed(moduleStateDb4, name);
4326
+ });
4327
+ }
4223
4328
  const sortedEntities = sortEntitiesByPriority(entities, notePath);
4224
4329
  const resolved = resolveAliasWikilinks(content, sortedEntities, {
4225
4330
  caseInsensitive: true
@@ -5553,14 +5658,14 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
5553
5658
  };
5554
5659
  function findSimilarEntity2(target, entities) {
5555
5660
  const targetLower = target.toLowerCase();
5556
- for (const [name, path24] of entities) {
5661
+ for (const [name, path25] of entities) {
5557
5662
  if (name.startsWith(targetLower) || targetLower.startsWith(name)) {
5558
- return path24;
5663
+ return path25;
5559
5664
  }
5560
5665
  }
5561
- for (const [name, path24] of entities) {
5666
+ for (const [name, path25] of entities) {
5562
5667
  if (name.includes(targetLower) || targetLower.includes(name)) {
5563
- return path24;
5668
+ return path25;
5564
5669
  }
5565
5670
  }
5566
5671
  return void 0;
@@ -6037,8 +6142,8 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
6037
6142
  daily_counts: z3.record(z3.number())
6038
6143
  }).describe("Activity summary for the last 7 days")
6039
6144
  };
6040
- function isPeriodicNote(path24) {
6041
- const filename = path24.split("/").pop() || "";
6145
+ function isPeriodicNote(path25) {
6146
+ const filename = path25.split("/").pop() || "";
6042
6147
  const nameWithoutExt = filename.replace(/\.md$/, "");
6043
6148
  const patterns = [
6044
6149
  /^\d{4}-\d{2}-\d{2}$/,
@@ -6053,7 +6158,7 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
6053
6158
  // YYYY (yearly)
6054
6159
  ];
6055
6160
  const periodicFolders = ["daily", "weekly", "monthly", "quarterly", "yearly", "journal", "journals"];
6056
- const folder = path24.split("/")[0]?.toLowerCase() || "";
6161
+ const folder = path25.split("/")[0]?.toLowerCase() || "";
6057
6162
  return patterns.some((p) => p.test(nameWithoutExt)) || periodicFolders.includes(folder);
6058
6163
  }
6059
6164
  server2.registerTool(
@@ -7058,18 +7163,18 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
7058
7163
  include_content: z6.boolean().default(false).describe("Include the text content under each top-level section")
7059
7164
  }
7060
7165
  },
7061
- async ({ path: path24, include_content }) => {
7166
+ async ({ path: path25, include_content }) => {
7062
7167
  const index = getIndex();
7063
7168
  const vaultPath2 = getVaultPath();
7064
- const result = await getNoteStructure(index, path24, vaultPath2);
7169
+ const result = await getNoteStructure(index, path25, vaultPath2);
7065
7170
  if (!result) {
7066
7171
  return {
7067
- content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path24 }, null, 2) }]
7172
+ content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path25 }, null, 2) }]
7068
7173
  };
7069
7174
  }
7070
7175
  if (include_content) {
7071
7176
  for (const section of result.sections) {
7072
- const sectionResult = await getSectionContent(index, path24, section.heading.text, vaultPath2, true);
7177
+ const sectionResult = await getSectionContent(index, path25, section.heading.text, vaultPath2, true);
7073
7178
  if (sectionResult) {
7074
7179
  section.content = sectionResult.content;
7075
7180
  }
@@ -7091,15 +7196,15 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
7091
7196
  include_subheadings: z6.boolean().default(true).describe("Include content under subheadings")
7092
7197
  }
7093
7198
  },
7094
- async ({ path: path24, heading, include_subheadings }) => {
7199
+ async ({ path: path25, heading, include_subheadings }) => {
7095
7200
  const index = getIndex();
7096
7201
  const vaultPath2 = getVaultPath();
7097
- const result = await getSectionContent(index, path24, heading, vaultPath2, include_subheadings);
7202
+ const result = await getSectionContent(index, path25, heading, vaultPath2, include_subheadings);
7098
7203
  if (!result) {
7099
7204
  return {
7100
7205
  content: [{ type: "text", text: JSON.stringify({
7101
7206
  error: "Section not found",
7102
- path: path24,
7207
+ path: path25,
7103
7208
  heading
7104
7209
  }, null, 2) }]
7105
7210
  };
@@ -7153,16 +7258,16 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
7153
7258
  offset: z6.coerce.number().default(0).describe("Number of results to skip (for pagination)")
7154
7259
  }
7155
7260
  },
7156
- async ({ path: path24, status, has_due_date, folder, tag, limit: requestedLimit, offset }) => {
7261
+ async ({ path: path25, status, has_due_date, folder, tag, limit: requestedLimit, offset }) => {
7157
7262
  const limit = Math.min(requestedLimit ?? 25, MAX_LIMIT);
7158
7263
  const index = getIndex();
7159
7264
  const vaultPath2 = getVaultPath();
7160
7265
  const config = getConfig();
7161
- if (path24) {
7162
- const result2 = await getTasksFromNote(index, path24, vaultPath2, config.exclude_task_tags || []);
7266
+ if (path25) {
7267
+ const result2 = await getTasksFromNote(index, path25, vaultPath2, config.exclude_task_tags || []);
7163
7268
  if (!result2) {
7164
7269
  return {
7165
- content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path24 }, null, 2) }]
7270
+ content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path25 }, null, 2) }]
7166
7271
  };
7167
7272
  }
7168
7273
  let filtered = result2;
@@ -7172,7 +7277,7 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
7172
7277
  const paged2 = filtered.slice(offset, offset + limit);
7173
7278
  return {
7174
7279
  content: [{ type: "text", text: JSON.stringify({
7175
- path: path24,
7280
+ path: path25,
7176
7281
  total_count: filtered.length,
7177
7282
  returned_count: paged2.length,
7178
7283
  open: result2.filter((t) => t.status === "open").length,
@@ -9614,8 +9719,8 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
9614
9719
  const templatePath = path18.join(vaultPath2, template);
9615
9720
  try {
9616
9721
  const raw = await fs18.readFile(templatePath, "utf-8");
9617
- const matter8 = (await import("gray-matter")).default;
9618
- const parsed = matter8(raw);
9722
+ const matter9 = (await import("gray-matter")).default;
9723
+ const parsed = matter9(raw);
9619
9724
  const dateStr = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
9620
9725
  const title = path18.basename(notePath, ".md");
9621
9726
  let templateContent = parsed.content.replace(/\{\{date\}\}/g, dateStr).replace(/\{\{title\}\}/g, title);
@@ -11580,6 +11685,531 @@ function registerPolicyTools(server2, vaultPath2) {
11580
11685
  );
11581
11686
  }
11582
11687
 
11688
+ // src/tools/write/tags.ts
11689
+ import { z as z19 } from "zod";
11690
+
11691
+ // src/core/write/tagRename.ts
11692
+ import * as fs24 from "fs/promises";
11693
+ import * as path24 from "path";
11694
+ import matter8 from "gray-matter";
11695
+ import { getProtectedZones } from "@velvetmonkey/vault-core";
11696
+ function getNotesInFolder3(index, folder) {
11697
+ const notes = [];
11698
+ for (const note of index.notes.values()) {
11699
+ const noteFolder = note.path.includes("/") ? note.path.substring(0, note.path.lastIndexOf("/")) : "";
11700
+ if (!folder || note.path.startsWith(folder + "/") || noteFolder === folder) {
11701
+ notes.push(note);
11702
+ }
11703
+ }
11704
+ return notes;
11705
+ }
11706
+ function tagMatches(tag, oldTag, renameChildren) {
11707
+ const tagLower = tag.toLowerCase();
11708
+ const oldLower = oldTag.toLowerCase();
11709
+ if (tagLower === oldLower) return true;
11710
+ if (renameChildren && tagLower.startsWith(oldLower + "/")) return true;
11711
+ return false;
11712
+ }
11713
+ function transformTag(tag, oldTag, newTag) {
11714
+ const tagLower = tag.toLowerCase();
11715
+ const oldLower = oldTag.toLowerCase();
11716
+ if (tagLower === oldLower) {
11717
+ return newTag;
11718
+ }
11719
+ if (tagLower.startsWith(oldLower + "/")) {
11720
+ const suffix = tag.substring(oldTag.length);
11721
+ return newTag + suffix;
11722
+ }
11723
+ return tag;
11724
+ }
11725
+ function isProtected(start, end, zones) {
11726
+ for (const zone of zones) {
11727
+ if (zone.type === "hashtag") continue;
11728
+ if (zone.type === "frontmatter") continue;
11729
+ if (start >= zone.start && start < zone.end || end > zone.start && end <= zone.end || start <= zone.start && end >= zone.end) {
11730
+ return true;
11731
+ }
11732
+ }
11733
+ return false;
11734
+ }
11735
+ function replaceInlineTags(content, oldTag, newTag, renameChildren) {
11736
+ const zones = getProtectedZones(content);
11737
+ const changes = [];
11738
+ const escapedOld = oldTag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11739
+ const pattern = renameChildren ? new RegExp(`(^|\\s)#(${escapedOld}(?:/[a-zA-Z0-9_/-]*)?)(?=[\\s,;.!?)]|$)`, "gim") : new RegExp(`(^|\\s)#(${escapedOld})(?=[/\\s,;.!?)]|$)`, "gim");
11740
+ const lineStarts = [0];
11741
+ for (let i = 0; i < content.length; i++) {
11742
+ if (content[i] === "\n") lineStarts.push(i + 1);
11743
+ }
11744
+ function getLineNumber(pos) {
11745
+ for (let i = lineStarts.length - 1; i >= 0; i--) {
11746
+ if (pos >= lineStarts[i]) return i + 1;
11747
+ }
11748
+ return 1;
11749
+ }
11750
+ const matches = [];
11751
+ let match;
11752
+ while ((match = pattern.exec(content)) !== null) {
11753
+ const prefix = match[1];
11754
+ const matchedTag = match[2];
11755
+ const tagStart = match.index + prefix.length + 1;
11756
+ const tagEnd = tagStart + matchedTag.length;
11757
+ if (isProtected(match.index, tagEnd, zones)) continue;
11758
+ if (!tagMatches(matchedTag, oldTag, renameChildren)) continue;
11759
+ matches.push({
11760
+ index: match.index,
11761
+ fullMatch: match[0],
11762
+ prefix,
11763
+ matchedTag
11764
+ });
11765
+ }
11766
+ let result = content;
11767
+ for (let i = matches.length - 1; i >= 0; i--) {
11768
+ const m = matches[i];
11769
+ const transformed = transformTag(m.matchedTag, oldTag, newTag);
11770
+ const replacement = m.prefix + "#" + transformed;
11771
+ const start = m.index;
11772
+ const end = start + m.fullMatch.length;
11773
+ result = result.substring(0, start) + replacement + result.substring(end);
11774
+ changes.unshift({
11775
+ old: "#" + m.matchedTag,
11776
+ new: "#" + transformed,
11777
+ line: getLineNumber(start)
11778
+ });
11779
+ }
11780
+ return { content: result, changes };
11781
+ }
11782
+ async function renameTag(index, vaultPath2, oldTag, newTag, options) {
11783
+ const renameChildren = options?.rename_children ?? true;
11784
+ const dryRun = options?.dry_run ?? true;
11785
+ const folder = options?.folder;
11786
+ const cleanOld = oldTag.replace(/^#/, "");
11787
+ const cleanNew = newTag.replace(/^#/, "");
11788
+ const notes = getNotesInFolder3(index, folder);
11789
+ const affectedNotes = [];
11790
+ for (const note of notes) {
11791
+ const hasTag2 = note.tags.some((t) => tagMatches(t, cleanOld, renameChildren));
11792
+ if (hasTag2) {
11793
+ affectedNotes.push(note);
11794
+ }
11795
+ }
11796
+ const previews = [];
11797
+ let totalChanges = 0;
11798
+ for (const note of affectedNotes) {
11799
+ const fullPath = path24.join(vaultPath2, note.path);
11800
+ let fileContent;
11801
+ try {
11802
+ fileContent = await fs24.readFile(fullPath, "utf-8");
11803
+ } catch {
11804
+ continue;
11805
+ }
11806
+ const preview = {
11807
+ path: note.path,
11808
+ frontmatter_changes: [],
11809
+ content_changes: [],
11810
+ total_changes: 0
11811
+ };
11812
+ let parsed;
11813
+ try {
11814
+ parsed = matter8(fileContent);
11815
+ } catch {
11816
+ continue;
11817
+ }
11818
+ const fm = parsed.data;
11819
+ let fmChanged = false;
11820
+ if (Array.isArray(fm.tags)) {
11821
+ const newTags = [];
11822
+ const seen = /* @__PURE__ */ new Set();
11823
+ for (const tag of fm.tags) {
11824
+ if (typeof tag !== "string") continue;
11825
+ const stripped = tag.replace(/^#/, "");
11826
+ if (!tagMatches(stripped, cleanOld, renameChildren)) {
11827
+ seen.add(stripped.toLowerCase());
11828
+ }
11829
+ }
11830
+ for (const tag of fm.tags) {
11831
+ if (typeof tag !== "string") {
11832
+ newTags.push(tag);
11833
+ continue;
11834
+ }
11835
+ const stripped = tag.replace(/^#/, "");
11836
+ if (tagMatches(stripped, cleanOld, renameChildren)) {
11837
+ const transformed = transformTag(stripped, cleanOld, cleanNew);
11838
+ const key = transformed.toLowerCase();
11839
+ if (seen.has(key)) {
11840
+ preview.frontmatter_changes.push({
11841
+ old: stripped,
11842
+ new: `${transformed} (merged)`
11843
+ });
11844
+ fmChanged = true;
11845
+ continue;
11846
+ }
11847
+ seen.add(key);
11848
+ preview.frontmatter_changes.push({
11849
+ old: stripped,
11850
+ new: transformed
11851
+ });
11852
+ newTags.push(transformed);
11853
+ fmChanged = true;
11854
+ } else {
11855
+ newTags.push(tag);
11856
+ }
11857
+ }
11858
+ if (fmChanged) {
11859
+ fm.tags = newTags;
11860
+ }
11861
+ }
11862
+ const { content: updatedContent, changes: contentChanges } = replaceInlineTags(
11863
+ parsed.content,
11864
+ cleanOld,
11865
+ cleanNew,
11866
+ renameChildren
11867
+ );
11868
+ preview.content_changes = contentChanges;
11869
+ preview.total_changes = preview.frontmatter_changes.length + preview.content_changes.length;
11870
+ totalChanges += preview.total_changes;
11871
+ if (preview.total_changes > 0) {
11872
+ previews.push(preview);
11873
+ if (!dryRun) {
11874
+ const newContent = matter8.stringify(updatedContent, fm);
11875
+ await fs24.writeFile(fullPath, newContent, "utf-8");
11876
+ }
11877
+ }
11878
+ }
11879
+ return {
11880
+ old_tag: cleanOld,
11881
+ new_tag: cleanNew,
11882
+ rename_children: renameChildren,
11883
+ dry_run: dryRun,
11884
+ affected_notes: previews.length,
11885
+ total_changes: totalChanges,
11886
+ previews
11887
+ };
11888
+ }
11889
+
11890
+ // src/tools/write/tags.ts
11891
+ function registerTagTools(server2, getIndex, getVaultPath) {
11892
+ server2.registerTool(
11893
+ "rename_tag",
11894
+ {
11895
+ title: "Rename Tag",
11896
+ description: "Bulk rename a tag across all notes (frontmatter and inline). Supports hierarchical rename (#project \u2192 #work also transforms #project/active \u2192 #work/active). Dry-run by default (preview only). Handles deduplication when new tag already exists.",
11897
+ inputSchema: {
11898
+ old_tag: z19.string().describe('Tag to rename (without #, e.g., "project")'),
11899
+ new_tag: z19.string().describe('New tag name (without #, e.g., "work")'),
11900
+ rename_children: z19.boolean().optional().describe("Also rename child tags (e.g., #project/active \u2192 #work/active). Default: true"),
11901
+ folder: z19.string().optional().describe('Limit to notes in this folder (e.g., "projects")'),
11902
+ dry_run: z19.boolean().optional().describe("Preview only, no changes (default: true)"),
11903
+ commit: z19.boolean().optional().describe("Commit changes to git (default: false)")
11904
+ }
11905
+ },
11906
+ async ({ old_tag, new_tag, rename_children, folder, dry_run, commit }) => {
11907
+ const index = getIndex();
11908
+ const vaultPath2 = getVaultPath();
11909
+ const result = await renameTag(index, vaultPath2, old_tag, new_tag, {
11910
+ rename_children: rename_children ?? true,
11911
+ folder,
11912
+ dry_run: dry_run ?? true,
11913
+ commit: commit ?? false
11914
+ });
11915
+ return {
11916
+ content: [
11917
+ {
11918
+ type: "text",
11919
+ text: JSON.stringify(result, null, 2)
11920
+ }
11921
+ ]
11922
+ };
11923
+ }
11924
+ );
11925
+ }
11926
+
11927
+ // src/tools/write/wikilinkFeedback.ts
11928
+ import { z as z20 } from "zod";
11929
+ function registerWikilinkFeedbackTools(server2, getStateDb) {
11930
+ server2.registerTool(
11931
+ "wikilink_feedback",
11932
+ {
11933
+ title: "Wikilink Feedback",
11934
+ description: 'Report and query wikilink accuracy feedback. Modes: "report" (record feedback), "list" (view recent feedback), "stats" (entity accuracy statistics). Entities with >=30% false positive rate (and >=10 samples) are auto-suppressed from future wikilink application.',
11935
+ inputSchema: {
11936
+ mode: z20.enum(["report", "list", "stats"]).describe("Operation mode"),
11937
+ entity: z20.string().optional().describe("Entity name (required for report mode, optional filter for list/stats)"),
11938
+ note_path: z20.string().optional().describe("Note path where the wikilink appeared (for report mode)"),
11939
+ context: z20.string().optional().describe("Surrounding text context (for report mode)"),
11940
+ correct: z20.boolean().optional().describe("Whether the wikilink was correct (for report mode)"),
11941
+ limit: z20.number().optional().describe("Max entries to return for list mode (default: 20)")
11942
+ }
11943
+ },
11944
+ async ({ mode, entity, note_path, context, correct, limit }) => {
11945
+ const stateDb2 = getStateDb();
11946
+ if (!stateDb2) {
11947
+ return {
11948
+ content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available" }) }]
11949
+ };
11950
+ }
11951
+ let result;
11952
+ switch (mode) {
11953
+ case "report": {
11954
+ if (!entity || correct === void 0) {
11955
+ return {
11956
+ content: [{ type: "text", text: JSON.stringify({ error: "entity and correct are required for report mode" }) }]
11957
+ };
11958
+ }
11959
+ recordFeedback(stateDb2, entity, context || "", note_path || "", correct);
11960
+ const suppressionUpdated = updateSuppressionList(stateDb2) > 0;
11961
+ result = {
11962
+ mode: "report",
11963
+ reported: {
11964
+ entity,
11965
+ correct,
11966
+ suppression_updated: suppressionUpdated
11967
+ },
11968
+ total_suppressed: getSuppressedCount(stateDb2)
11969
+ };
11970
+ break;
11971
+ }
11972
+ case "list": {
11973
+ const entries = getFeedback(stateDb2, entity, limit ?? 20);
11974
+ result = {
11975
+ mode: "list",
11976
+ entries,
11977
+ total_feedback: entries.length
11978
+ };
11979
+ break;
11980
+ }
11981
+ case "stats": {
11982
+ const stats = getEntityStats(stateDb2);
11983
+ result = {
11984
+ mode: "stats",
11985
+ stats,
11986
+ total_feedback: stats.reduce((sum, s) => sum + s.total, 0),
11987
+ total_suppressed: getSuppressedCount(stateDb2)
11988
+ };
11989
+ break;
11990
+ }
11991
+ }
11992
+ return {
11993
+ content: [
11994
+ {
11995
+ type: "text",
11996
+ text: JSON.stringify(result, null, 2)
11997
+ }
11998
+ ]
11999
+ };
12000
+ }
12001
+ );
12002
+ }
12003
+
12004
+ // src/tools/read/metrics.ts
12005
+ import { z as z21 } from "zod";
12006
+
12007
+ // src/core/shared/metrics.ts
12008
+ var ALL_METRICS = [
12009
+ "note_count",
12010
+ "link_count",
12011
+ "orphan_count",
12012
+ "tag_count",
12013
+ "entity_count",
12014
+ "avg_links_per_note",
12015
+ "link_density",
12016
+ "connected_ratio"
12017
+ ];
12018
+ function computeMetrics(index) {
12019
+ const noteCount = index.notes.size;
12020
+ let linkCount = 0;
12021
+ for (const note of index.notes.values()) {
12022
+ linkCount += note.outlinks.length;
12023
+ }
12024
+ const connectedNotes = /* @__PURE__ */ new Set();
12025
+ for (const [notePath, note] of index.notes) {
12026
+ if (note.outlinks.length > 0) {
12027
+ connectedNotes.add(notePath);
12028
+ }
12029
+ }
12030
+ for (const [target, backlinks] of index.backlinks) {
12031
+ for (const bl of backlinks) {
12032
+ connectedNotes.add(bl.source);
12033
+ }
12034
+ for (const note of index.notes.values()) {
12035
+ const normalizedTitle = note.title.toLowerCase();
12036
+ if (normalizedTitle === target.toLowerCase() || note.path.toLowerCase() === target.toLowerCase()) {
12037
+ connectedNotes.add(note.path);
12038
+ }
12039
+ }
12040
+ }
12041
+ let orphanCount = 0;
12042
+ for (const [notePath, note] of index.notes) {
12043
+ if (!connectedNotes.has(notePath)) {
12044
+ orphanCount++;
12045
+ }
12046
+ }
12047
+ const tagCount = index.tags.size;
12048
+ const entityCount = index.entities.size;
12049
+ const avgLinksPerNote = noteCount > 0 ? linkCount / noteCount : 0;
12050
+ const possibleLinks = noteCount * (noteCount - 1);
12051
+ const linkDensity = possibleLinks > 0 ? linkCount / possibleLinks : 0;
12052
+ const connectedRatio = noteCount > 0 ? connectedNotes.size / noteCount : 0;
12053
+ return {
12054
+ note_count: noteCount,
12055
+ link_count: linkCount,
12056
+ orphan_count: orphanCount,
12057
+ tag_count: tagCount,
12058
+ entity_count: entityCount,
12059
+ avg_links_per_note: Math.round(avgLinksPerNote * 100) / 100,
12060
+ link_density: Math.round(linkDensity * 1e4) / 1e4,
12061
+ connected_ratio: Math.round(connectedRatio * 1e3) / 1e3
12062
+ };
12063
+ }
12064
+ function recordMetrics(stateDb2, metrics) {
12065
+ const timestamp = Date.now();
12066
+ const insert = stateDb2.db.prepare(
12067
+ "INSERT INTO vault_metrics (timestamp, metric, value) VALUES (?, ?, ?)"
12068
+ );
12069
+ const transaction = stateDb2.db.transaction(() => {
12070
+ for (const [metric, value] of Object.entries(metrics)) {
12071
+ insert.run(timestamp, metric, value);
12072
+ }
12073
+ });
12074
+ transaction();
12075
+ }
12076
+ function getMetricHistory(stateDb2, metric, daysBack = 30) {
12077
+ const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1e3;
12078
+ let rows;
12079
+ if (metric) {
12080
+ rows = stateDb2.db.prepare(
12081
+ "SELECT timestamp, metric, value FROM vault_metrics WHERE metric = ? AND timestamp >= ? ORDER BY timestamp"
12082
+ ).all(metric, cutoff);
12083
+ } else {
12084
+ rows = stateDb2.db.prepare(
12085
+ "SELECT timestamp, metric, value FROM vault_metrics WHERE timestamp >= ? ORDER BY timestamp"
12086
+ ).all(cutoff);
12087
+ }
12088
+ return rows.map((r) => ({
12089
+ metric: r.metric,
12090
+ value: r.value,
12091
+ timestamp: r.timestamp
12092
+ }));
12093
+ }
12094
+ function computeTrends(stateDb2, currentMetrics, daysBack = 30) {
12095
+ const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1e3;
12096
+ const rows = stateDb2.db.prepare(`
12097
+ SELECT metric, value FROM vault_metrics
12098
+ WHERE timestamp >= ? AND timestamp <= ?
12099
+ GROUP BY metric
12100
+ HAVING timestamp = MIN(timestamp)
12101
+ `).all(cutoff, cutoff + 24 * 60 * 60 * 1e3);
12102
+ const previousValues = /* @__PURE__ */ new Map();
12103
+ for (const row of rows) {
12104
+ previousValues.set(row.metric, row.value);
12105
+ }
12106
+ if (previousValues.size === 0) {
12107
+ const fallbackRows = stateDb2.db.prepare(`
12108
+ SELECT metric, MIN(value) as value FROM vault_metrics
12109
+ WHERE timestamp >= ?
12110
+ GROUP BY metric
12111
+ HAVING timestamp = MIN(timestamp)
12112
+ `).all(cutoff);
12113
+ for (const row of fallbackRows) {
12114
+ previousValues.set(row.metric, row.value);
12115
+ }
12116
+ }
12117
+ const trends = [];
12118
+ for (const metricName of ALL_METRICS) {
12119
+ const current = currentMetrics[metricName] ?? 0;
12120
+ const previous = previousValues.get(metricName) ?? current;
12121
+ const delta = current - previous;
12122
+ const deltaPct = previous !== 0 ? Math.round(delta / previous * 1e4) / 100 : delta !== 0 ? 100 : 0;
12123
+ let direction = "stable";
12124
+ if (delta > 0) direction = "up";
12125
+ if (delta < 0) direction = "down";
12126
+ trends.push({
12127
+ metric: metricName,
12128
+ current,
12129
+ previous,
12130
+ delta,
12131
+ delta_percent: deltaPct,
12132
+ direction
12133
+ });
12134
+ }
12135
+ return trends;
12136
+ }
12137
+ function purgeOldMetrics(stateDb2, retentionDays = 90) {
12138
+ const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1e3;
12139
+ const result = stateDb2.db.prepare(
12140
+ "DELETE FROM vault_metrics WHERE timestamp < ?"
12141
+ ).run(cutoff);
12142
+ return result.changes;
12143
+ }
12144
+
12145
+ // src/tools/read/metrics.ts
12146
+ function registerMetricsTools(server2, getIndex, getStateDb) {
12147
+ server2.registerTool(
12148
+ "vault_growth",
12149
+ {
12150
+ title: "Vault Growth",
12151
+ description: 'Track vault growth over time. Modes: "current" (live snapshot), "history" (time series), "trends" (deltas vs N days ago). Tracks 8 metrics: note_count, link_count, orphan_count, tag_count, entity_count, avg_links_per_note, link_density, connected_ratio.',
12152
+ inputSchema: {
12153
+ mode: z21.enum(["current", "history", "trends"]).describe("Query mode: current snapshot, historical time series, or trend analysis"),
12154
+ metric: z21.string().optional().describe('Filter to specific metric (e.g., "note_count"). Omit for all metrics.'),
12155
+ days_back: z21.number().optional().describe("Number of days to look back for history/trends (default: 30)")
12156
+ }
12157
+ },
12158
+ async ({ mode, metric, days_back }) => {
12159
+ const index = getIndex();
12160
+ const stateDb2 = getStateDb();
12161
+ const daysBack = days_back ?? 30;
12162
+ let result;
12163
+ switch (mode) {
12164
+ case "current": {
12165
+ const metrics = computeMetrics(index);
12166
+ result = {
12167
+ mode: "current",
12168
+ metrics,
12169
+ recorded_at: Date.now()
12170
+ };
12171
+ break;
12172
+ }
12173
+ case "history": {
12174
+ if (!stateDb2) {
12175
+ return {
12176
+ content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available for historical queries" }) }]
12177
+ };
12178
+ }
12179
+ const history = getMetricHistory(stateDb2, metric, daysBack);
12180
+ result = {
12181
+ mode: "history",
12182
+ history
12183
+ };
12184
+ break;
12185
+ }
12186
+ case "trends": {
12187
+ if (!stateDb2) {
12188
+ return {
12189
+ content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available for trend analysis" }) }]
12190
+ };
12191
+ }
12192
+ const currentMetrics = computeMetrics(index);
12193
+ const trends = computeTrends(stateDb2, currentMetrics, daysBack);
12194
+ result = {
12195
+ mode: "trends",
12196
+ trends
12197
+ };
12198
+ break;
12199
+ }
12200
+ }
12201
+ return {
12202
+ content: [
12203
+ {
12204
+ type: "text",
12205
+ text: JSON.stringify(result, null, 2)
12206
+ }
12207
+ ]
12208
+ };
12209
+ }
12210
+ );
12211
+ }
12212
+
11583
12213
  // src/resources/vault.ts
11584
12214
  function registerVaultResources(server2, getIndex) {
11585
12215
  server2.registerResource(
@@ -11812,9 +12442,14 @@ var TOOL_CATEGORY = {
11812
12442
  vault_undo_last_mutation: "git",
11813
12443
  // policy
11814
12444
  policy: "policy",
11815
- // schema (migrations)
12445
+ // schema (migrations + tag rename)
11816
12446
  rename_field: "schema",
11817
- migrate_field_values: "schema"
12447
+ migrate_field_values: "schema",
12448
+ rename_tag: "schema",
12449
+ // health (growth metrics)
12450
+ vault_growth: "health",
12451
+ // wikilinks (feedback)
12452
+ wikilink_feedback: "wikilinks"
11818
12453
  };
11819
12454
  var server = new McpServer({
11820
12455
  name: "flywheel-memory",
@@ -11873,6 +12508,9 @@ registerNoteTools(server, vaultPath, () => vaultIndex);
11873
12508
  registerMoveNoteTools(server, vaultPath);
11874
12509
  registerSystemTools2(server, vaultPath);
11875
12510
  registerPolicyTools(server, vaultPath);
12511
+ registerTagTools(server, () => vaultIndex, () => vaultPath);
12512
+ registerWikilinkFeedbackTools(server, () => stateDb);
12513
+ registerMetricsTools(server, () => vaultIndex, () => stateDb);
11876
12514
  registerVaultResources(server, () => vaultIndex ?? null);
11877
12515
  console.error(`[Memory] Registered ${_registeredCount} tools, skipped ${_skippedCount}`);
11878
12516
  async function main() {
@@ -11978,6 +12616,23 @@ async function updateEntitiesInStateDb() {
11978
12616
  async function runPostIndexWork(index) {
11979
12617
  await updateEntitiesInStateDb();
11980
12618
  await exportHubScores(index, stateDb);
12619
+ if (stateDb) {
12620
+ try {
12621
+ const metrics = computeMetrics(index);
12622
+ recordMetrics(stateDb, metrics);
12623
+ purgeOldMetrics(stateDb, 90);
12624
+ console.error("[Memory] Growth metrics recorded");
12625
+ } catch (err) {
12626
+ console.error("[Memory] Failed to record metrics:", err);
12627
+ }
12628
+ }
12629
+ if (stateDb) {
12630
+ try {
12631
+ updateSuppressionList(stateDb);
12632
+ } catch (err) {
12633
+ console.error("[Memory] Failed to update suppression list:", err);
12634
+ }
12635
+ }
11981
12636
  const existing = loadConfig(stateDb);
11982
12637
  const inferred = inferConfig(index, vaultPath);
11983
12638
  if (stateDb) {
@@ -12043,8 +12698,8 @@ async function runPostIndexWork(index) {
12043
12698
  }
12044
12699
  });
12045
12700
  let rebuildTimer;
12046
- legacyWatcher.on("all", (event, path24) => {
12047
- if (!path24.endsWith(".md")) return;
12701
+ legacyWatcher.on("all", (event, path25) => {
12702
+ if (!path25.endsWith(".md")) return;
12048
12703
  clearTimeout(rebuildTimer);
12049
12704
  rebuildTimer = setTimeout(() => {
12050
12705
  console.error("[Memory] Rebuilding index (file changed)");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.0.10",
3
+ "version": "2.0.11",
4
4
  "description": "MCP server that gives Claude full read/write access to your Obsidian vault. 36 tools for search, backlinks, graph queries, and mutations.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@modelcontextprotocol/sdk": "^1.25.1",
53
- "@velvetmonkey/vault-core": "^2.0.10",
53
+ "@velvetmonkey/vault-core": "^2.0.11",
54
54
  "better-sqlite3": "^11.0.0",
55
55
  "chokidar": "^4.0.0",
56
56
  "gray-matter": "^4.0.3",