@velvetmonkey/flywheel-memory 2.0.10 → 2.0.12

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 +964 -80
  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,237 @@ 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
+ var FEEDBACK_BOOST_MIN_SAMPLES = 5;
2735
+ var FOLDER_SUPPRESSION_MIN_COUNT = 5;
2736
+ var FEEDBACK_BOOST_TIERS = [
2737
+ { minAccuracy: 0.95, minSamples: 20, boost: 5 },
2738
+ { minAccuracy: 0.8, minSamples: 5, boost: 2 },
2739
+ { minAccuracy: 0.6, minSamples: 5, boost: 0 },
2740
+ { minAccuracy: 0.4, minSamples: 5, boost: -2 },
2741
+ { minAccuracy: 0, minSamples: 5, boost: -4 }
2742
+ ];
2743
+ function recordFeedback(stateDb2, entity, context, notePath, correct) {
2744
+ stateDb2.db.prepare(
2745
+ "INSERT INTO wikilink_feedback (entity, context, note_path, correct) VALUES (?, ?, ?, ?)"
2746
+ ).run(entity, context, notePath, correct ? 1 : 0);
2747
+ }
2748
+ function getFeedback(stateDb2, entity, limit = 20) {
2749
+ let rows;
2750
+ if (entity) {
2751
+ rows = stateDb2.db.prepare(
2752
+ "SELECT id, entity, context, note_path, correct, created_at FROM wikilink_feedback WHERE entity = ? ORDER BY created_at DESC LIMIT ?"
2753
+ ).all(entity, limit);
2754
+ } else {
2755
+ rows = stateDb2.db.prepare(
2756
+ "SELECT id, entity, context, note_path, correct, created_at FROM wikilink_feedback ORDER BY created_at DESC LIMIT ?"
2757
+ ).all(limit);
2758
+ }
2759
+ return rows.map((r) => ({
2760
+ id: r.id,
2761
+ entity: r.entity,
2762
+ context: r.context,
2763
+ note_path: r.note_path,
2764
+ correct: r.correct === 1,
2765
+ created_at: r.created_at
2766
+ }));
2767
+ }
2768
+ function getEntityStats(stateDb2) {
2769
+ const rows = stateDb2.db.prepare(`
2770
+ SELECT
2771
+ entity,
2772
+ COUNT(*) as total,
2773
+ SUM(CASE WHEN correct = 1 THEN 1 ELSE 0 END) as correct_count,
2774
+ SUM(CASE WHEN correct = 0 THEN 1 ELSE 0 END) as incorrect_count
2775
+ FROM wikilink_feedback
2776
+ GROUP BY entity
2777
+ ORDER BY total DESC
2778
+ `).all();
2779
+ return rows.map((r) => {
2780
+ const suppressed = isSuppressed(stateDb2, r.entity);
2781
+ return {
2782
+ entity: r.entity,
2783
+ total: r.total,
2784
+ correct: r.correct_count,
2785
+ incorrect: r.incorrect_count,
2786
+ accuracy: r.total > 0 ? Math.round(r.correct_count / r.total * 1e3) / 1e3 : 0,
2787
+ suppressed
2788
+ };
2789
+ });
2790
+ }
2791
+ function updateSuppressionList(stateDb2) {
2792
+ const stats = stateDb2.db.prepare(`
2793
+ SELECT
2794
+ entity,
2795
+ COUNT(*) as total,
2796
+ SUM(CASE WHEN correct = 0 THEN 1 ELSE 0 END) as false_positives
2797
+ FROM wikilink_feedback
2798
+ GROUP BY entity
2799
+ HAVING total >= ?
2800
+ `).all(MIN_FEEDBACK_COUNT);
2801
+ let updated = 0;
2802
+ const upsert = stateDb2.db.prepare(`
2803
+ INSERT INTO wikilink_suppressions (entity, false_positive_rate, updated_at)
2804
+ VALUES (?, ?, datetime('now'))
2805
+ ON CONFLICT(entity) DO UPDATE SET
2806
+ false_positive_rate = excluded.false_positive_rate,
2807
+ updated_at = datetime('now')
2808
+ `);
2809
+ const remove = stateDb2.db.prepare(
2810
+ "DELETE FROM wikilink_suppressions WHERE entity = ?"
2811
+ );
2812
+ const transaction = stateDb2.db.transaction(() => {
2813
+ for (const stat3 of stats) {
2814
+ const fpRate = stat3.false_positives / stat3.total;
2815
+ if (fpRate >= SUPPRESSION_THRESHOLD) {
2816
+ upsert.run(stat3.entity, fpRate);
2817
+ updated++;
2818
+ } else {
2819
+ remove.run(stat3.entity);
2820
+ }
2821
+ }
2822
+ });
2823
+ transaction();
2824
+ return updated;
2825
+ }
2826
+ function isSuppressed(stateDb2, entity, folder) {
2827
+ const row = stateDb2.db.prepare(
2828
+ "SELECT entity FROM wikilink_suppressions WHERE entity = ?"
2829
+ ).get(entity);
2830
+ if (row) return true;
2831
+ if (folder !== void 0) {
2832
+ const folderStats = stateDb2.db.prepare(`
2833
+ SELECT
2834
+ COUNT(*) as total,
2835
+ SUM(CASE WHEN correct = 0 THEN 1 ELSE 0 END) as false_positives
2836
+ FROM wikilink_feedback
2837
+ WHERE entity = ? AND (
2838
+ CASE WHEN ? = '' THEN note_path NOT LIKE '%/%'
2839
+ ELSE note_path LIKE ? || '/%'
2840
+ END
2841
+ )
2842
+ `).get(entity, folder, folder);
2843
+ if (folderStats && folderStats.total >= FOLDER_SUPPRESSION_MIN_COUNT) {
2844
+ const fpRate = folderStats.false_positives / folderStats.total;
2845
+ if (fpRate >= SUPPRESSION_THRESHOLD) {
2846
+ return true;
2847
+ }
2848
+ }
2849
+ }
2850
+ return false;
2851
+ }
2852
+ function getSuppressedCount(stateDb2) {
2853
+ const row = stateDb2.db.prepare(
2854
+ "SELECT COUNT(*) as count FROM wikilink_suppressions"
2855
+ ).get();
2856
+ return row.count;
2857
+ }
2858
+ function computeBoostFromAccuracy(accuracy, sampleCount) {
2859
+ if (sampleCount < FEEDBACK_BOOST_MIN_SAMPLES) return 0;
2860
+ for (const tier of FEEDBACK_BOOST_TIERS) {
2861
+ if (accuracy >= tier.minAccuracy && sampleCount >= tier.minSamples) {
2862
+ return tier.boost;
2863
+ }
2864
+ }
2865
+ return 0;
2866
+ }
2867
+ function getAllFeedbackBoosts(stateDb2, folder) {
2868
+ const globalRows = stateDb2.db.prepare(`
2869
+ SELECT
2870
+ entity,
2871
+ COUNT(*) as total,
2872
+ SUM(CASE WHEN correct = 1 THEN 1 ELSE 0 END) as correct_count
2873
+ FROM wikilink_feedback
2874
+ GROUP BY entity
2875
+ HAVING total >= ?
2876
+ `).all(FEEDBACK_BOOST_MIN_SAMPLES);
2877
+ let folderStats = null;
2878
+ if (folder !== void 0) {
2879
+ const folderRows = stateDb2.db.prepare(`
2880
+ SELECT
2881
+ entity,
2882
+ COUNT(*) as total,
2883
+ SUM(CASE WHEN correct = 1 THEN 1 ELSE 0 END) as correct_count
2884
+ FROM wikilink_feedback
2885
+ WHERE (
2886
+ CASE WHEN ? = '' THEN note_path NOT LIKE '%/%'
2887
+ ELSE note_path LIKE ? || '/%'
2888
+ END
2889
+ )
2890
+ GROUP BY entity
2891
+ HAVING total >= ?
2892
+ `).all(folder, folder, FEEDBACK_BOOST_MIN_SAMPLES);
2893
+ folderStats = /* @__PURE__ */ new Map();
2894
+ for (const row of folderRows) {
2895
+ folderStats.set(row.entity, {
2896
+ accuracy: row.correct_count / row.total,
2897
+ count: row.total
2898
+ });
2899
+ }
2900
+ }
2901
+ const boosts = /* @__PURE__ */ new Map();
2902
+ for (const row of globalRows) {
2903
+ let accuracy;
2904
+ let sampleCount;
2905
+ const fs25 = folderStats?.get(row.entity);
2906
+ if (fs25 && fs25.count >= FEEDBACK_BOOST_MIN_SAMPLES) {
2907
+ accuracy = fs25.accuracy;
2908
+ sampleCount = fs25.count;
2909
+ } else {
2910
+ accuracy = row.correct_count / row.total;
2911
+ sampleCount = row.total;
2912
+ }
2913
+ const boost = computeBoostFromAccuracy(accuracy, sampleCount);
2914
+ if (boost !== 0) {
2915
+ boosts.set(row.entity, boost);
2916
+ }
2917
+ }
2918
+ return boosts;
2919
+ }
2920
+ function trackWikilinkApplications(stateDb2, notePath, entities) {
2921
+ const upsert = stateDb2.db.prepare(`
2922
+ INSERT INTO wikilink_applications (entity, note_path, applied_at, status)
2923
+ VALUES (?, ?, datetime('now'), 'applied')
2924
+ ON CONFLICT(entity, note_path) DO UPDATE SET
2925
+ applied_at = datetime('now'),
2926
+ status = 'applied'
2927
+ `);
2928
+ const transaction = stateDb2.db.transaction(() => {
2929
+ for (const entity of entities) {
2930
+ upsert.run(entity.toLowerCase(), notePath);
2931
+ }
2932
+ });
2933
+ transaction();
2934
+ }
2935
+ function getTrackedApplications(stateDb2, notePath) {
2936
+ const rows = stateDb2.db.prepare(
2937
+ `SELECT entity FROM wikilink_applications WHERE note_path = ? AND status = 'applied'`
2938
+ ).all(notePath);
2939
+ return rows.map((r) => r.entity);
2940
+ }
2941
+ function processImplicitFeedback(stateDb2, notePath, currentContent) {
2942
+ const tracked = getTrackedApplications(stateDb2, notePath);
2943
+ if (tracked.length === 0) return [];
2944
+ const currentLinks = extractLinkedEntities(currentContent);
2945
+ const removed = [];
2946
+ const markRemoved = stateDb2.db.prepare(
2947
+ `UPDATE wikilink_applications SET status = 'removed' WHERE entity = ? AND note_path = ?`
2948
+ );
2949
+ const transaction = stateDb2.db.transaction(() => {
2950
+ for (const entity of tracked) {
2951
+ if (!currentLinks.has(entity)) {
2952
+ recordFeedback(stateDb2, entity, "implicit:removed", notePath, false);
2953
+ markRemoved.run(entity, notePath);
2954
+ removed.push(entity);
2955
+ }
2956
+ }
2957
+ });
2958
+ transaction();
2959
+ return removed;
2960
+ }
2961
+
2731
2962
  // src/core/write/git.ts
2732
2963
  import { simpleGit, CheckRepoActions } from "simple-git";
2733
2964
  import path5 from "path";
@@ -4075,6 +4306,9 @@ function setWriteStateDb(stateDb2) {
4075
4306
  setHintsStateDb(stateDb2);
4076
4307
  setRecencyStateDb(stateDb2);
4077
4308
  }
4309
+ function getWriteStateDb() {
4310
+ return moduleStateDb4;
4311
+ }
4078
4312
  var entityIndex = null;
4079
4313
  var indexReady = false;
4080
4314
  var indexError2 = null;
@@ -4218,8 +4452,15 @@ function processWikilinks(content, notePath) {
4218
4452
  linkedEntities: []
4219
4453
  };
4220
4454
  }
4221
- const entities = getAllEntities(entityIndex);
4455
+ let entities = getAllEntities(entityIndex);
4222
4456
  console.error(`[Flywheel:DEBUG] Processing wikilinks with ${entities.length} entities`);
4457
+ if (moduleStateDb4) {
4458
+ const folder = notePath ? notePath.split("/")[0] : void 0;
4459
+ entities = entities.filter((e) => {
4460
+ const name = getEntityName2(e);
4461
+ return !isSuppressed(moduleStateDb4, name, folder);
4462
+ });
4463
+ }
4223
4464
  const sortedEntities = sortEntitiesByPriority(entities, notePath);
4224
4465
  const resolved = resolveAliasWikilinks(content, sortedEntities, {
4225
4466
  caseInsensitive: true
@@ -4241,6 +4482,9 @@ function maybeApplyWikilinks(content, skipWikilinks, notePath) {
4241
4482
  checkAndRefreshIfStale();
4242
4483
  const result = processWikilinks(content, notePath);
4243
4484
  if (result.linksAdded > 0) {
4485
+ if (moduleStateDb4 && notePath) {
4486
+ trackWikilinkApplications(moduleStateDb4, notePath, result.linkedEntities);
4487
+ }
4244
4488
  return {
4245
4489
  content: result.content,
4246
4490
  wikilinkInfo: `Applied ${result.linksAdded} wikilink(s): ${result.linkedEntities.join(", ")}`
@@ -4558,7 +4802,8 @@ function suggestRelatedLinks(content, options = {}) {
4558
4802
  maxSuggestions = 3,
4559
4803
  excludeLinked = true,
4560
4804
  strictness = DEFAULT_STRICTNESS,
4561
- notePath
4805
+ notePath,
4806
+ detail = false
4562
4807
  } = options;
4563
4808
  const config = STRICTNESS_CONFIGS[strictness];
4564
4809
  const adaptiveMinScore = getAdaptiveMinScore(content.length, config.minSuggestionScore);
@@ -4592,6 +4837,8 @@ function suggestRelatedLinks(content, options = {}) {
4592
4837
  return emptyResult;
4593
4838
  }
4594
4839
  const linkedEntities = excludeLinked ? extractLinkedEntities(content) : /* @__PURE__ */ new Set();
4840
+ const noteFolder = notePath ? notePath.split("/")[0] : void 0;
4841
+ const feedbackBoosts = moduleStateDb4 ? getAllFeedbackBoosts(moduleStateDb4, noteFolder) : /* @__PURE__ */ new Map();
4595
4842
  const scoredEntities = [];
4596
4843
  const directlyMatchedEntities = /* @__PURE__ */ new Set();
4597
4844
  const entitiesWithContentMatch = /* @__PURE__ */ new Set();
@@ -4612,20 +4859,38 @@ function suggestRelatedLinks(content, options = {}) {
4612
4859
  if (contentScore > 0) {
4613
4860
  entitiesWithContentMatch.add(entityName);
4614
4861
  }
4615
- score += TYPE_BOOST[category] || 0;
4616
- score += contextBoosts[category] || 0;
4617
- if (recencyIndex) {
4618
- score += getRecencyBoost(entityName, recencyIndex);
4619
- }
4620
- if (notePath && entity.path) {
4621
- score += getCrossFolderBoost(entity.path, notePath);
4622
- }
4623
- score += getHubBoost(entity);
4862
+ const layerTypeBoost = TYPE_BOOST[category] || 0;
4863
+ score += layerTypeBoost;
4864
+ const layerContextBoost = contextBoosts[category] || 0;
4865
+ score += layerContextBoost;
4866
+ const layerRecencyBoost = recencyIndex ? getRecencyBoost(entityName, recencyIndex) : 0;
4867
+ score += layerRecencyBoost;
4868
+ const layerCrossFolderBoost = notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
4869
+ score += layerCrossFolderBoost;
4870
+ const layerHubBoost = getHubBoost(entity);
4871
+ score += layerHubBoost;
4872
+ const layerFeedbackAdj = feedbackBoosts.get(entityName) ?? 0;
4873
+ score += layerFeedbackAdj;
4624
4874
  if (score > 0) {
4625
4875
  directlyMatchedEntities.add(entityName);
4626
4876
  }
4627
4877
  if (score >= adaptiveMinScore) {
4628
- scoredEntities.push({ name: entityName, score, category });
4878
+ scoredEntities.push({
4879
+ name: entityName,
4880
+ path: entity.path || "",
4881
+ score,
4882
+ category,
4883
+ breakdown: {
4884
+ contentMatch: contentScore,
4885
+ cooccurrenceBoost: 0,
4886
+ typeBoost: layerTypeBoost,
4887
+ contextBoost: layerContextBoost,
4888
+ recencyBoost: layerRecencyBoost,
4889
+ crossFolderBoost: layerCrossFolderBoost,
4890
+ hubBoost: layerHubBoost,
4891
+ feedbackAdjustment: layerFeedbackAdj
4892
+ }
4893
+ });
4629
4894
  }
4630
4895
  }
4631
4896
  if (cooccurrenceIndex && directlyMatchedEntities.size > 0) {
@@ -4640,6 +4905,7 @@ function suggestRelatedLinks(content, options = {}) {
4640
4905
  const existing = scoredEntities.find((e) => e.name === entityName);
4641
4906
  if (existing) {
4642
4907
  existing.score += boost;
4908
+ existing.breakdown.cooccurrenceBoost += boost;
4643
4909
  } else {
4644
4910
  const entityTokens = tokenize(entityName);
4645
4911
  const hasContentOverlap = entityTokens.some(
@@ -4654,9 +4920,25 @@ function suggestRelatedLinks(content, options = {}) {
4654
4920
  const recencyBoostVal = recencyIndex ? getRecencyBoost(entityName, recencyIndex) : 0;
4655
4921
  const crossFolderBoost = notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
4656
4922
  const hubBoost = getHubBoost(entity);
4657
- const totalBoost = boost + typeBoost + contextBoost + recencyBoostVal + crossFolderBoost + hubBoost;
4923
+ const feedbackAdj = feedbackBoosts.get(entityName) ?? 0;
4924
+ const totalBoost = boost + typeBoost + contextBoost + recencyBoostVal + crossFolderBoost + hubBoost + feedbackAdj;
4658
4925
  if (totalBoost >= adaptiveMinScore) {
4659
- scoredEntities.push({ name: entityName, score: totalBoost, category });
4926
+ scoredEntities.push({
4927
+ name: entityName,
4928
+ path: entity.path || "",
4929
+ score: totalBoost,
4930
+ category,
4931
+ breakdown: {
4932
+ contentMatch: 0,
4933
+ cooccurrenceBoost: boost,
4934
+ typeBoost,
4935
+ contextBoost,
4936
+ recencyBoost: recencyBoostVal,
4937
+ crossFolderBoost,
4938
+ hubBoost,
4939
+ feedbackAdjustment: feedbackAdj
4940
+ }
4941
+ });
4660
4942
  }
4661
4943
  }
4662
4944
  }
@@ -4677,15 +4959,34 @@ function suggestRelatedLinks(content, options = {}) {
4677
4959
  }
4678
4960
  return 0;
4679
4961
  });
4680
- const topSuggestions = relevantEntities.slice(0, maxSuggestions).map((e) => e.name);
4962
+ const topEntries = relevantEntities.slice(0, maxSuggestions);
4963
+ const topSuggestions = topEntries.map((e) => e.name);
4681
4964
  if (topSuggestions.length === 0) {
4682
4965
  return emptyResult;
4683
4966
  }
4684
4967
  const suffix = "\u2192 " + topSuggestions.map((name) => `[[${name}]]`).join(", ");
4685
- return {
4968
+ const result = {
4686
4969
  suggestions: topSuggestions,
4687
4970
  suffix
4688
4971
  };
4972
+ if (detail) {
4973
+ const feedbackStats = moduleStateDb4 ? getEntityStats(moduleStateDb4) : [];
4974
+ const feedbackMap = new Map(feedbackStats.map((s) => [s.entity, s]));
4975
+ result.detailed = topEntries.map((e) => {
4976
+ const fb = feedbackMap.get(e.name);
4977
+ const confidence = e.score >= 20 ? "high" : e.score >= 12 ? "medium" : "low";
4978
+ return {
4979
+ entity: e.name,
4980
+ path: e.path,
4981
+ totalScore: e.score,
4982
+ breakdown: e.breakdown,
4983
+ confidence,
4984
+ feedbackCount: fb?.total ?? 0,
4985
+ accuracy: fb ? fb.accuracy : void 0
4986
+ };
4987
+ });
4988
+ }
4989
+ return result;
4689
4990
  }
4690
4991
  function detectAliasCollisions(noteName, aliases = []) {
4691
4992
  if (!moduleStateDb4) return [];
@@ -5511,11 +5812,12 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
5511
5812
  inputSchema: {
5512
5813
  text: z2.string().describe("The text to analyze for potential wikilinks"),
5513
5814
  limit: z2.coerce.number().default(50).describe("Maximum number of suggestions to return"),
5514
- offset: z2.coerce.number().default(0).describe("Number of suggestions to skip (for pagination)")
5815
+ offset: z2.coerce.number().default(0).describe("Number of suggestions to skip (for pagination)"),
5816
+ detail: z2.boolean().default(false).describe("Include per-layer score breakdown for each suggestion")
5515
5817
  },
5516
5818
  outputSchema: SuggestWikilinksOutputSchema
5517
5819
  },
5518
- async ({ text, limit: requestedLimit, offset }) => {
5820
+ async ({ text, limit: requestedLimit, offset, detail }) => {
5519
5821
  const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
5520
5822
  const index = getIndex();
5521
5823
  const allMatches = findEntityMatches(text, index.entities);
@@ -5526,6 +5828,16 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
5526
5828
  returned_count: matches.length,
5527
5829
  suggestions: matches
5528
5830
  };
5831
+ if (detail) {
5832
+ const scored = suggestRelatedLinks(text, {
5833
+ detail: true,
5834
+ maxSuggestions: limit,
5835
+ strictness: "balanced"
5836
+ });
5837
+ if (scored.detailed) {
5838
+ output.scored_suggestions = scored.detailed;
5839
+ }
5840
+ }
5529
5841
  return {
5530
5842
  content: [
5531
5843
  {
@@ -5553,14 +5865,14 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
5553
5865
  };
5554
5866
  function findSimilarEntity2(target, entities) {
5555
5867
  const targetLower = target.toLowerCase();
5556
- for (const [name, path24] of entities) {
5868
+ for (const [name, path25] of entities) {
5557
5869
  if (name.startsWith(targetLower) || targetLower.startsWith(name)) {
5558
- return path24;
5870
+ return path25;
5559
5871
  }
5560
5872
  }
5561
- for (const [name, path24] of entities) {
5873
+ for (const [name, path25] of entities) {
5562
5874
  if (name.includes(targetLower) || targetLower.includes(name)) {
5563
- return path24;
5875
+ return path25;
5564
5876
  }
5565
5877
  }
5566
5878
  return void 0;
@@ -6037,8 +6349,8 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
6037
6349
  daily_counts: z3.record(z3.number())
6038
6350
  }).describe("Activity summary for the last 7 days")
6039
6351
  };
6040
- function isPeriodicNote(path24) {
6041
- const filename = path24.split("/").pop() || "";
6352
+ function isPeriodicNote(path25) {
6353
+ const filename = path25.split("/").pop() || "";
6042
6354
  const nameWithoutExt = filename.replace(/\.md$/, "");
6043
6355
  const patterns = [
6044
6356
  /^\d{4}-\d{2}-\d{2}$/,
@@ -6053,7 +6365,7 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
6053
6365
  // YYYY (yearly)
6054
6366
  ];
6055
6367
  const periodicFolders = ["daily", "weekly", "monthly", "quarterly", "yearly", "journal", "journals"];
6056
- const folder = path24.split("/")[0]?.toLowerCase() || "";
6368
+ const folder = path25.split("/")[0]?.toLowerCase() || "";
6057
6369
  return patterns.some((p) => p.test(nameWithoutExt)) || periodicFolders.includes(folder);
6058
6370
  }
6059
6371
  server2.registerTool(
@@ -7058,18 +7370,18 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
7058
7370
  include_content: z6.boolean().default(false).describe("Include the text content under each top-level section")
7059
7371
  }
7060
7372
  },
7061
- async ({ path: path24, include_content }) => {
7373
+ async ({ path: path25, include_content }) => {
7062
7374
  const index = getIndex();
7063
7375
  const vaultPath2 = getVaultPath();
7064
- const result = await getNoteStructure(index, path24, vaultPath2);
7376
+ const result = await getNoteStructure(index, path25, vaultPath2);
7065
7377
  if (!result) {
7066
7378
  return {
7067
- content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path24 }, null, 2) }]
7379
+ content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path25 }, null, 2) }]
7068
7380
  };
7069
7381
  }
7070
7382
  if (include_content) {
7071
7383
  for (const section of result.sections) {
7072
- const sectionResult = await getSectionContent(index, path24, section.heading.text, vaultPath2, true);
7384
+ const sectionResult = await getSectionContent(index, path25, section.heading.text, vaultPath2, true);
7073
7385
  if (sectionResult) {
7074
7386
  section.content = sectionResult.content;
7075
7387
  }
@@ -7091,15 +7403,15 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
7091
7403
  include_subheadings: z6.boolean().default(true).describe("Include content under subheadings")
7092
7404
  }
7093
7405
  },
7094
- async ({ path: path24, heading, include_subheadings }) => {
7406
+ async ({ path: path25, heading, include_subheadings }) => {
7095
7407
  const index = getIndex();
7096
7408
  const vaultPath2 = getVaultPath();
7097
- const result = await getSectionContent(index, path24, heading, vaultPath2, include_subheadings);
7409
+ const result = await getSectionContent(index, path25, heading, vaultPath2, include_subheadings);
7098
7410
  if (!result) {
7099
7411
  return {
7100
7412
  content: [{ type: "text", text: JSON.stringify({
7101
7413
  error: "Section not found",
7102
- path: path24,
7414
+ path: path25,
7103
7415
  heading
7104
7416
  }, null, 2) }]
7105
7417
  };
@@ -7153,16 +7465,16 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
7153
7465
  offset: z6.coerce.number().default(0).describe("Number of results to skip (for pagination)")
7154
7466
  }
7155
7467
  },
7156
- async ({ path: path24, status, has_due_date, folder, tag, limit: requestedLimit, offset }) => {
7468
+ async ({ path: path25, status, has_due_date, folder, tag, limit: requestedLimit, offset }) => {
7157
7469
  const limit = Math.min(requestedLimit ?? 25, MAX_LIMIT);
7158
7470
  const index = getIndex();
7159
7471
  const vaultPath2 = getVaultPath();
7160
7472
  const config = getConfig();
7161
- if (path24) {
7162
- const result2 = await getTasksFromNote(index, path24, vaultPath2, config.exclude_task_tags || []);
7473
+ if (path25) {
7474
+ const result2 = await getTasksFromNote(index, path25, vaultPath2, config.exclude_task_tags || []);
7163
7475
  if (!result2) {
7164
7476
  return {
7165
- content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path24 }, null, 2) }]
7477
+ content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path25 }, null, 2) }]
7166
7478
  };
7167
7479
  }
7168
7480
  let filtered = result2;
@@ -7172,7 +7484,7 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
7172
7484
  const paged2 = filtered.slice(offset, offset + limit);
7173
7485
  return {
7174
7486
  content: [{ type: "text", text: JSON.stringify({
7175
- path: path24,
7487
+ path: path25,
7176
7488
  total_count: filtered.length,
7177
7489
  returned_count: paged2.length,
7178
7490
  open: result2.filter((t) => t.status === "open").length,
@@ -8998,6 +9310,10 @@ async function withVaultFile(options, operation) {
8998
9310
  return formatMcpResult(existsError);
8999
9311
  }
9000
9312
  const { content, frontmatter, lineEnding } = await readVaultFile(vaultPath2, notePath);
9313
+ const writeStateDb = getWriteStateDb();
9314
+ if (writeStateDb) {
9315
+ processImplicitFeedback(writeStateDb, notePath, content);
9316
+ }
9001
9317
  let sectionBoundary;
9002
9318
  if (section) {
9003
9319
  const sectionResult = ensureSectionExists(content, section, notePath);
@@ -9614,8 +9930,8 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
9614
9930
  const templatePath = path18.join(vaultPath2, template);
9615
9931
  try {
9616
9932
  const raw = await fs18.readFile(templatePath, "utf-8");
9617
- const matter8 = (await import("gray-matter")).default;
9618
- const parsed = matter8(raw);
9933
+ const matter9 = (await import("gray-matter")).default;
9934
+ const parsed = matter9(raw);
9619
9935
  const dateStr = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
9620
9936
  const title = path18.basename(notePath, ".md");
9621
9937
  let templateContent = parsed.content.replace(/\{\{date\}\}/g, dateStr).replace(/\{\{title\}\}/g, title);
@@ -11580,6 +11896,549 @@ function registerPolicyTools(server2, vaultPath2) {
11580
11896
  );
11581
11897
  }
11582
11898
 
11899
+ // src/tools/write/tags.ts
11900
+ import { z as z19 } from "zod";
11901
+
11902
+ // src/core/write/tagRename.ts
11903
+ import * as fs24 from "fs/promises";
11904
+ import * as path24 from "path";
11905
+ import matter8 from "gray-matter";
11906
+ import { getProtectedZones } from "@velvetmonkey/vault-core";
11907
+ function getNotesInFolder3(index, folder) {
11908
+ const notes = [];
11909
+ for (const note of index.notes.values()) {
11910
+ const noteFolder = note.path.includes("/") ? note.path.substring(0, note.path.lastIndexOf("/")) : "";
11911
+ if (!folder || note.path.startsWith(folder + "/") || noteFolder === folder) {
11912
+ notes.push(note);
11913
+ }
11914
+ }
11915
+ return notes;
11916
+ }
11917
+ function tagMatches(tag, oldTag, renameChildren) {
11918
+ const tagLower = tag.toLowerCase();
11919
+ const oldLower = oldTag.toLowerCase();
11920
+ if (tagLower === oldLower) return true;
11921
+ if (renameChildren && tagLower.startsWith(oldLower + "/")) return true;
11922
+ return false;
11923
+ }
11924
+ function transformTag(tag, oldTag, newTag) {
11925
+ const tagLower = tag.toLowerCase();
11926
+ const oldLower = oldTag.toLowerCase();
11927
+ if (tagLower === oldLower) {
11928
+ return newTag;
11929
+ }
11930
+ if (tagLower.startsWith(oldLower + "/")) {
11931
+ const suffix = tag.substring(oldTag.length);
11932
+ return newTag + suffix;
11933
+ }
11934
+ return tag;
11935
+ }
11936
+ function isProtected(start, end, zones) {
11937
+ for (const zone of zones) {
11938
+ if (zone.type === "hashtag") continue;
11939
+ if (zone.type === "frontmatter") continue;
11940
+ if (start >= zone.start && start < zone.end || end > zone.start && end <= zone.end || start <= zone.start && end >= zone.end) {
11941
+ return true;
11942
+ }
11943
+ }
11944
+ return false;
11945
+ }
11946
+ function replaceInlineTags(content, oldTag, newTag, renameChildren) {
11947
+ const zones = getProtectedZones(content);
11948
+ const changes = [];
11949
+ const escapedOld = oldTag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11950
+ const pattern = renameChildren ? new RegExp(`(^|\\s)#(${escapedOld}(?:/[a-zA-Z0-9_/-]*)?)(?=[\\s,;.!?)]|$)`, "gim") : new RegExp(`(^|\\s)#(${escapedOld})(?=[/\\s,;.!?)]|$)`, "gim");
11951
+ const lineStarts = [0];
11952
+ for (let i = 0; i < content.length; i++) {
11953
+ if (content[i] === "\n") lineStarts.push(i + 1);
11954
+ }
11955
+ function getLineNumber(pos) {
11956
+ for (let i = lineStarts.length - 1; i >= 0; i--) {
11957
+ if (pos >= lineStarts[i]) return i + 1;
11958
+ }
11959
+ return 1;
11960
+ }
11961
+ const matches = [];
11962
+ let match;
11963
+ while ((match = pattern.exec(content)) !== null) {
11964
+ const prefix = match[1];
11965
+ const matchedTag = match[2];
11966
+ const tagStart = match.index + prefix.length + 1;
11967
+ const tagEnd = tagStart + matchedTag.length;
11968
+ if (isProtected(match.index, tagEnd, zones)) continue;
11969
+ if (!tagMatches(matchedTag, oldTag, renameChildren)) continue;
11970
+ matches.push({
11971
+ index: match.index,
11972
+ fullMatch: match[0],
11973
+ prefix,
11974
+ matchedTag
11975
+ });
11976
+ }
11977
+ let result = content;
11978
+ for (let i = matches.length - 1; i >= 0; i--) {
11979
+ const m = matches[i];
11980
+ const transformed = transformTag(m.matchedTag, oldTag, newTag);
11981
+ const replacement = m.prefix + "#" + transformed;
11982
+ const start = m.index;
11983
+ const end = start + m.fullMatch.length;
11984
+ result = result.substring(0, start) + replacement + result.substring(end);
11985
+ changes.unshift({
11986
+ old: "#" + m.matchedTag,
11987
+ new: "#" + transformed,
11988
+ line: getLineNumber(start)
11989
+ });
11990
+ }
11991
+ return { content: result, changes };
11992
+ }
11993
+ async function renameTag(index, vaultPath2, oldTag, newTag, options) {
11994
+ const renameChildren = options?.rename_children ?? true;
11995
+ const dryRun = options?.dry_run ?? true;
11996
+ const folder = options?.folder;
11997
+ const cleanOld = oldTag.replace(/^#/, "");
11998
+ const cleanNew = newTag.replace(/^#/, "");
11999
+ const notes = getNotesInFolder3(index, folder);
12000
+ const affectedNotes = [];
12001
+ for (const note of notes) {
12002
+ const hasTag2 = note.tags.some((t) => tagMatches(t, cleanOld, renameChildren));
12003
+ if (hasTag2) {
12004
+ affectedNotes.push(note);
12005
+ }
12006
+ }
12007
+ const previews = [];
12008
+ let totalChanges = 0;
12009
+ for (const note of affectedNotes) {
12010
+ const fullPath = path24.join(vaultPath2, note.path);
12011
+ let fileContent;
12012
+ try {
12013
+ fileContent = await fs24.readFile(fullPath, "utf-8");
12014
+ } catch {
12015
+ continue;
12016
+ }
12017
+ const preview = {
12018
+ path: note.path,
12019
+ frontmatter_changes: [],
12020
+ content_changes: [],
12021
+ total_changes: 0
12022
+ };
12023
+ let parsed;
12024
+ try {
12025
+ parsed = matter8(fileContent);
12026
+ } catch {
12027
+ continue;
12028
+ }
12029
+ const fm = parsed.data;
12030
+ let fmChanged = false;
12031
+ if (Array.isArray(fm.tags)) {
12032
+ const newTags = [];
12033
+ const seen = /* @__PURE__ */ new Set();
12034
+ for (const tag of fm.tags) {
12035
+ if (typeof tag !== "string") continue;
12036
+ const stripped = tag.replace(/^#/, "");
12037
+ if (!tagMatches(stripped, cleanOld, renameChildren)) {
12038
+ seen.add(stripped.toLowerCase());
12039
+ }
12040
+ }
12041
+ for (const tag of fm.tags) {
12042
+ if (typeof tag !== "string") {
12043
+ newTags.push(tag);
12044
+ continue;
12045
+ }
12046
+ const stripped = tag.replace(/^#/, "");
12047
+ if (tagMatches(stripped, cleanOld, renameChildren)) {
12048
+ const transformed = transformTag(stripped, cleanOld, cleanNew);
12049
+ const key = transformed.toLowerCase();
12050
+ if (seen.has(key)) {
12051
+ preview.frontmatter_changes.push({
12052
+ old: stripped,
12053
+ new: `${transformed} (merged)`
12054
+ });
12055
+ fmChanged = true;
12056
+ continue;
12057
+ }
12058
+ seen.add(key);
12059
+ preview.frontmatter_changes.push({
12060
+ old: stripped,
12061
+ new: transformed
12062
+ });
12063
+ newTags.push(transformed);
12064
+ fmChanged = true;
12065
+ } else {
12066
+ newTags.push(tag);
12067
+ }
12068
+ }
12069
+ if (fmChanged) {
12070
+ fm.tags = newTags;
12071
+ }
12072
+ }
12073
+ const { content: updatedContent, changes: contentChanges } = replaceInlineTags(
12074
+ parsed.content,
12075
+ cleanOld,
12076
+ cleanNew,
12077
+ renameChildren
12078
+ );
12079
+ preview.content_changes = contentChanges;
12080
+ preview.total_changes = preview.frontmatter_changes.length + preview.content_changes.length;
12081
+ totalChanges += preview.total_changes;
12082
+ if (preview.total_changes > 0) {
12083
+ previews.push(preview);
12084
+ if (!dryRun) {
12085
+ const newContent = matter8.stringify(updatedContent, fm);
12086
+ await fs24.writeFile(fullPath, newContent, "utf-8");
12087
+ }
12088
+ }
12089
+ }
12090
+ return {
12091
+ old_tag: cleanOld,
12092
+ new_tag: cleanNew,
12093
+ rename_children: renameChildren,
12094
+ dry_run: dryRun,
12095
+ affected_notes: previews.length,
12096
+ total_changes: totalChanges,
12097
+ previews
12098
+ };
12099
+ }
12100
+
12101
+ // src/tools/write/tags.ts
12102
+ function registerTagTools(server2, getIndex, getVaultPath) {
12103
+ server2.registerTool(
12104
+ "rename_tag",
12105
+ {
12106
+ title: "Rename Tag",
12107
+ 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.",
12108
+ inputSchema: {
12109
+ old_tag: z19.string().describe('Tag to rename (without #, e.g., "project")'),
12110
+ new_tag: z19.string().describe('New tag name (without #, e.g., "work")'),
12111
+ rename_children: z19.boolean().optional().describe("Also rename child tags (e.g., #project/active \u2192 #work/active). Default: true"),
12112
+ folder: z19.string().optional().describe('Limit to notes in this folder (e.g., "projects")'),
12113
+ dry_run: z19.boolean().optional().describe("Preview only, no changes (default: true)"),
12114
+ commit: z19.boolean().optional().describe("Commit changes to git (default: false)")
12115
+ }
12116
+ },
12117
+ async ({ old_tag, new_tag, rename_children, folder, dry_run, commit }) => {
12118
+ const index = getIndex();
12119
+ const vaultPath2 = getVaultPath();
12120
+ const result = await renameTag(index, vaultPath2, old_tag, new_tag, {
12121
+ rename_children: rename_children ?? true,
12122
+ folder,
12123
+ dry_run: dry_run ?? true,
12124
+ commit: commit ?? false
12125
+ });
12126
+ return {
12127
+ content: [
12128
+ {
12129
+ type: "text",
12130
+ text: JSON.stringify(result, null, 2)
12131
+ }
12132
+ ]
12133
+ };
12134
+ }
12135
+ );
12136
+ }
12137
+
12138
+ // src/tools/write/wikilinkFeedback.ts
12139
+ import { z as z20 } from "zod";
12140
+ function registerWikilinkFeedbackTools(server2, getStateDb) {
12141
+ server2.registerTool(
12142
+ "wikilink_feedback",
12143
+ {
12144
+ title: "Wikilink Feedback",
12145
+ 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.',
12146
+ inputSchema: {
12147
+ mode: z20.enum(["report", "list", "stats"]).describe("Operation mode"),
12148
+ entity: z20.string().optional().describe("Entity name (required for report mode, optional filter for list/stats)"),
12149
+ note_path: z20.string().optional().describe("Note path where the wikilink appeared (for report mode)"),
12150
+ context: z20.string().optional().describe("Surrounding text context (for report mode)"),
12151
+ correct: z20.boolean().optional().describe("Whether the wikilink was correct (for report mode)"),
12152
+ limit: z20.number().optional().describe("Max entries to return for list mode (default: 20)")
12153
+ }
12154
+ },
12155
+ async ({ mode, entity, note_path, context, correct, limit }) => {
12156
+ const stateDb2 = getStateDb();
12157
+ if (!stateDb2) {
12158
+ return {
12159
+ content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available" }) }]
12160
+ };
12161
+ }
12162
+ let result;
12163
+ switch (mode) {
12164
+ case "report": {
12165
+ if (!entity || correct === void 0) {
12166
+ return {
12167
+ content: [{ type: "text", text: JSON.stringify({ error: "entity and correct are required for report mode" }) }]
12168
+ };
12169
+ }
12170
+ recordFeedback(stateDb2, entity, context || "", note_path || "", correct);
12171
+ const suppressionUpdated = updateSuppressionList(stateDb2) > 0;
12172
+ result = {
12173
+ mode: "report",
12174
+ reported: {
12175
+ entity,
12176
+ correct,
12177
+ suppression_updated: suppressionUpdated
12178
+ },
12179
+ total_suppressed: getSuppressedCount(stateDb2)
12180
+ };
12181
+ break;
12182
+ }
12183
+ case "list": {
12184
+ const entries = getFeedback(stateDb2, entity, limit ?? 20);
12185
+ result = {
12186
+ mode: "list",
12187
+ entries,
12188
+ total_feedback: entries.length
12189
+ };
12190
+ break;
12191
+ }
12192
+ case "stats": {
12193
+ const stats = getEntityStats(stateDb2);
12194
+ result = {
12195
+ mode: "stats",
12196
+ stats,
12197
+ total_feedback: stats.reduce((sum, s) => sum + s.total, 0),
12198
+ total_suppressed: getSuppressedCount(stateDb2)
12199
+ };
12200
+ break;
12201
+ }
12202
+ }
12203
+ return {
12204
+ content: [
12205
+ {
12206
+ type: "text",
12207
+ text: JSON.stringify(result, null, 2)
12208
+ }
12209
+ ]
12210
+ };
12211
+ }
12212
+ );
12213
+ }
12214
+
12215
+ // src/tools/read/metrics.ts
12216
+ import { z as z21 } from "zod";
12217
+
12218
+ // src/core/shared/metrics.ts
12219
+ var ALL_METRICS = [
12220
+ "note_count",
12221
+ "link_count",
12222
+ "orphan_count",
12223
+ "tag_count",
12224
+ "entity_count",
12225
+ "avg_links_per_note",
12226
+ "link_density",
12227
+ "connected_ratio",
12228
+ "wikilink_accuracy",
12229
+ "wikilink_feedback_volume",
12230
+ "wikilink_suppressed_count"
12231
+ ];
12232
+ function computeMetrics(index, stateDb2) {
12233
+ const noteCount = index.notes.size;
12234
+ let linkCount = 0;
12235
+ for (const note of index.notes.values()) {
12236
+ linkCount += note.outlinks.length;
12237
+ }
12238
+ const connectedNotes = /* @__PURE__ */ new Set();
12239
+ for (const [notePath, note] of index.notes) {
12240
+ if (note.outlinks.length > 0) {
12241
+ connectedNotes.add(notePath);
12242
+ }
12243
+ }
12244
+ for (const [target, backlinks] of index.backlinks) {
12245
+ for (const bl of backlinks) {
12246
+ connectedNotes.add(bl.source);
12247
+ }
12248
+ for (const note of index.notes.values()) {
12249
+ const normalizedTitle = note.title.toLowerCase();
12250
+ if (normalizedTitle === target.toLowerCase() || note.path.toLowerCase() === target.toLowerCase()) {
12251
+ connectedNotes.add(note.path);
12252
+ }
12253
+ }
12254
+ }
12255
+ let orphanCount = 0;
12256
+ for (const [notePath, note] of index.notes) {
12257
+ if (!connectedNotes.has(notePath)) {
12258
+ orphanCount++;
12259
+ }
12260
+ }
12261
+ const tagCount = index.tags.size;
12262
+ const entityCount = index.entities.size;
12263
+ const avgLinksPerNote = noteCount > 0 ? linkCount / noteCount : 0;
12264
+ const possibleLinks = noteCount * (noteCount - 1);
12265
+ const linkDensity = possibleLinks > 0 ? linkCount / possibleLinks : 0;
12266
+ const connectedRatio = noteCount > 0 ? connectedNotes.size / noteCount : 0;
12267
+ let wikilinkAccuracy = 0;
12268
+ let wikilinkFeedbackVolume = 0;
12269
+ let wikilinkSuppressedCount = 0;
12270
+ if (stateDb2) {
12271
+ const entityStatsList = getEntityStats(stateDb2);
12272
+ wikilinkFeedbackVolume = entityStatsList.reduce((sum, s) => sum + s.total, 0);
12273
+ if (wikilinkFeedbackVolume > 0) {
12274
+ const totalCorrect = entityStatsList.reduce((sum, s) => sum + s.correct, 0);
12275
+ wikilinkAccuracy = Math.round(totalCorrect / wikilinkFeedbackVolume * 1e3) / 1e3;
12276
+ }
12277
+ wikilinkSuppressedCount = getSuppressedCount(stateDb2);
12278
+ }
12279
+ return {
12280
+ note_count: noteCount,
12281
+ link_count: linkCount,
12282
+ orphan_count: orphanCount,
12283
+ tag_count: tagCount,
12284
+ entity_count: entityCount,
12285
+ avg_links_per_note: Math.round(avgLinksPerNote * 100) / 100,
12286
+ link_density: Math.round(linkDensity * 1e4) / 1e4,
12287
+ connected_ratio: Math.round(connectedRatio * 1e3) / 1e3,
12288
+ wikilink_accuracy: wikilinkAccuracy,
12289
+ wikilink_feedback_volume: wikilinkFeedbackVolume,
12290
+ wikilink_suppressed_count: wikilinkSuppressedCount
12291
+ };
12292
+ }
12293
+ function recordMetrics(stateDb2, metrics) {
12294
+ const timestamp = Date.now();
12295
+ const insert = stateDb2.db.prepare(
12296
+ "INSERT INTO vault_metrics (timestamp, metric, value) VALUES (?, ?, ?)"
12297
+ );
12298
+ const transaction = stateDb2.db.transaction(() => {
12299
+ for (const [metric, value] of Object.entries(metrics)) {
12300
+ insert.run(timestamp, metric, value);
12301
+ }
12302
+ });
12303
+ transaction();
12304
+ }
12305
+ function getMetricHistory(stateDb2, metric, daysBack = 30) {
12306
+ const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1e3;
12307
+ let rows;
12308
+ if (metric) {
12309
+ rows = stateDb2.db.prepare(
12310
+ "SELECT timestamp, metric, value FROM vault_metrics WHERE metric = ? AND timestamp >= ? ORDER BY timestamp"
12311
+ ).all(metric, cutoff);
12312
+ } else {
12313
+ rows = stateDb2.db.prepare(
12314
+ "SELECT timestamp, metric, value FROM vault_metrics WHERE timestamp >= ? ORDER BY timestamp"
12315
+ ).all(cutoff);
12316
+ }
12317
+ return rows.map((r) => ({
12318
+ metric: r.metric,
12319
+ value: r.value,
12320
+ timestamp: r.timestamp
12321
+ }));
12322
+ }
12323
+ function computeTrends(stateDb2, currentMetrics, daysBack = 30) {
12324
+ const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1e3;
12325
+ const rows = stateDb2.db.prepare(`
12326
+ SELECT metric, value FROM vault_metrics
12327
+ WHERE timestamp >= ? AND timestamp <= ?
12328
+ GROUP BY metric
12329
+ HAVING timestamp = MIN(timestamp)
12330
+ `).all(cutoff, cutoff + 24 * 60 * 60 * 1e3);
12331
+ const previousValues = /* @__PURE__ */ new Map();
12332
+ for (const row of rows) {
12333
+ previousValues.set(row.metric, row.value);
12334
+ }
12335
+ if (previousValues.size === 0) {
12336
+ const fallbackRows = stateDb2.db.prepare(`
12337
+ SELECT metric, MIN(value) as value FROM vault_metrics
12338
+ WHERE timestamp >= ?
12339
+ GROUP BY metric
12340
+ HAVING timestamp = MIN(timestamp)
12341
+ `).all(cutoff);
12342
+ for (const row of fallbackRows) {
12343
+ previousValues.set(row.metric, row.value);
12344
+ }
12345
+ }
12346
+ const trends = [];
12347
+ for (const metricName of ALL_METRICS) {
12348
+ const current = currentMetrics[metricName] ?? 0;
12349
+ const previous = previousValues.get(metricName) ?? current;
12350
+ const delta = current - previous;
12351
+ const deltaPct = previous !== 0 ? Math.round(delta / previous * 1e4) / 100 : delta !== 0 ? 100 : 0;
12352
+ let direction = "stable";
12353
+ if (delta > 0) direction = "up";
12354
+ if (delta < 0) direction = "down";
12355
+ trends.push({
12356
+ metric: metricName,
12357
+ current,
12358
+ previous,
12359
+ delta,
12360
+ delta_percent: deltaPct,
12361
+ direction
12362
+ });
12363
+ }
12364
+ return trends;
12365
+ }
12366
+ function purgeOldMetrics(stateDb2, retentionDays = 90) {
12367
+ const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1e3;
12368
+ const result = stateDb2.db.prepare(
12369
+ "DELETE FROM vault_metrics WHERE timestamp < ?"
12370
+ ).run(cutoff);
12371
+ return result.changes;
12372
+ }
12373
+
12374
+ // src/tools/read/metrics.ts
12375
+ function registerMetricsTools(server2, getIndex, getStateDb) {
12376
+ server2.registerTool(
12377
+ "vault_growth",
12378
+ {
12379
+ title: "Vault Growth",
12380
+ description: 'Track vault growth over time. Modes: "current" (live snapshot), "history" (time series), "trends" (deltas vs N days ago). 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.',
12381
+ inputSchema: {
12382
+ mode: z21.enum(["current", "history", "trends"]).describe("Query mode: current snapshot, historical time series, or trend analysis"),
12383
+ metric: z21.string().optional().describe('Filter to specific metric (e.g., "note_count"). Omit for all metrics.'),
12384
+ days_back: z21.number().optional().describe("Number of days to look back for history/trends (default: 30)")
12385
+ }
12386
+ },
12387
+ async ({ mode, metric, days_back }) => {
12388
+ const index = getIndex();
12389
+ const stateDb2 = getStateDb();
12390
+ const daysBack = days_back ?? 30;
12391
+ let result;
12392
+ switch (mode) {
12393
+ case "current": {
12394
+ const metrics = computeMetrics(index, stateDb2 ?? void 0);
12395
+ result = {
12396
+ mode: "current",
12397
+ metrics,
12398
+ recorded_at: Date.now()
12399
+ };
12400
+ break;
12401
+ }
12402
+ case "history": {
12403
+ if (!stateDb2) {
12404
+ return {
12405
+ content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available for historical queries" }) }]
12406
+ };
12407
+ }
12408
+ const history = getMetricHistory(stateDb2, metric, daysBack);
12409
+ result = {
12410
+ mode: "history",
12411
+ history
12412
+ };
12413
+ break;
12414
+ }
12415
+ case "trends": {
12416
+ if (!stateDb2) {
12417
+ return {
12418
+ content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available for trend analysis" }) }]
12419
+ };
12420
+ }
12421
+ const currentMetrics = computeMetrics(index, stateDb2);
12422
+ const trends = computeTrends(stateDb2, currentMetrics, daysBack);
12423
+ result = {
12424
+ mode: "trends",
12425
+ trends
12426
+ };
12427
+ break;
12428
+ }
12429
+ }
12430
+ return {
12431
+ content: [
12432
+ {
12433
+ type: "text",
12434
+ text: JSON.stringify(result, null, 2)
12435
+ }
12436
+ ]
12437
+ };
12438
+ }
12439
+ );
12440
+ }
12441
+
11583
12442
  // src/resources/vault.ts
11584
12443
  function registerVaultResources(server2, getIndex) {
11585
12444
  server2.registerResource(
@@ -11812,9 +12671,14 @@ var TOOL_CATEGORY = {
11812
12671
  vault_undo_last_mutation: "git",
11813
12672
  // policy
11814
12673
  policy: "policy",
11815
- // schema (migrations)
12674
+ // schema (migrations + tag rename)
11816
12675
  rename_field: "schema",
11817
- migrate_field_values: "schema"
12676
+ migrate_field_values: "schema",
12677
+ rename_tag: "schema",
12678
+ // health (growth metrics)
12679
+ vault_growth: "health",
12680
+ // wikilinks (feedback)
12681
+ wikilink_feedback: "wikilinks"
11818
12682
  };
11819
12683
  var server = new McpServer({
11820
12684
  name: "flywheel-memory",
@@ -11873,6 +12737,9 @@ registerNoteTools(server, vaultPath, () => vaultIndex);
11873
12737
  registerMoveNoteTools(server, vaultPath);
11874
12738
  registerSystemTools2(server, vaultPath);
11875
12739
  registerPolicyTools(server, vaultPath);
12740
+ registerTagTools(server, () => vaultIndex, () => vaultPath);
12741
+ registerWikilinkFeedbackTools(server, () => stateDb);
12742
+ registerMetricsTools(server, () => vaultIndex, () => stateDb);
11876
12743
  registerVaultResources(server, () => vaultIndex ?? null);
11877
12744
  console.error(`[Memory] Registered ${_registeredCount} tools, skipped ${_skippedCount}`);
11878
12745
  async function main() {
@@ -11978,6 +12845,23 @@ async function updateEntitiesInStateDb() {
11978
12845
  async function runPostIndexWork(index) {
11979
12846
  await updateEntitiesInStateDb();
11980
12847
  await exportHubScores(index, stateDb);
12848
+ if (stateDb) {
12849
+ try {
12850
+ const metrics = computeMetrics(index, stateDb);
12851
+ recordMetrics(stateDb, metrics);
12852
+ purgeOldMetrics(stateDb, 90);
12853
+ console.error("[Memory] Growth metrics recorded");
12854
+ } catch (err) {
12855
+ console.error("[Memory] Failed to record metrics:", err);
12856
+ }
12857
+ }
12858
+ if (stateDb) {
12859
+ try {
12860
+ updateSuppressionList(stateDb);
12861
+ } catch (err) {
12862
+ console.error("[Memory] Failed to update suppression list:", err);
12863
+ }
12864
+ }
11981
12865
  const existing = loadConfig(stateDb);
11982
12866
  const inferred = inferConfig(index, vaultPath);
11983
12867
  if (stateDb) {
@@ -12043,8 +12927,8 @@ async function runPostIndexWork(index) {
12043
12927
  }
12044
12928
  });
12045
12929
  let rebuildTimer;
12046
- legacyWatcher.on("all", (event, path24) => {
12047
- if (!path24.endsWith(".md")) return;
12930
+ legacyWatcher.on("all", (event, path25) => {
12931
+ if (!path25.endsWith(".md")) return;
12048
12932
  clearTimeout(rebuildTimer);
12049
12933
  rebuildTimer = setTimeout(() => {
12050
12934
  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.12",
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.12",
54
54
  "better-sqlite3": "^11.0.0",
55
55
  "chokidar": "^4.0.0",
56
56
  "gray-matter": "^4.0.3",