@velvetmonkey/flywheel-memory 2.0.47 → 2.0.49

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 +524 -184
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -3493,23 +3493,39 @@ import {
3493
3493
  } from "@velvetmonkey/vault-core";
3494
3494
 
3495
3495
  // src/core/write/wikilinkFeedback.ts
3496
- var MIN_FEEDBACK_COUNT = 5;
3497
- var SUPPRESSION_THRESHOLD = 0.3;
3498
- var FEEDBACK_BOOST_MIN_SAMPLES = 5;
3496
+ var PRIOR_ALPHA = 8;
3497
+ var PRIOR_BETA = 1;
3498
+ var SUPPRESSION_POSTERIOR_THRESHOLD = 0.35;
3499
+ var SUPPRESSION_MIN_OBSERVATIONS = 20;
3500
+ var MAX_SUPPRESSION_PENALTY = -15;
3501
+ var FEEDBACK_BOOST_MIN_SAMPLES = 3;
3499
3502
  var FOLDER_SUPPRESSION_MIN_COUNT = 5;
3503
+ var SUPPRESSION_TTL_DAYS = 30;
3504
+ var FEEDBACK_DECAY_HALF_LIFE_DAYS = 30;
3505
+ var FEEDBACK_DECAY_LAMBDA = Math.LN2 / FEEDBACK_DECAY_HALF_LIFE_DAYS;
3506
+ function computePosteriorMean(weightedCorrect, weightedFp) {
3507
+ const alpha = PRIOR_ALPHA + weightedCorrect;
3508
+ const beta_ = PRIOR_BETA + weightedFp;
3509
+ return alpha / (alpha + beta_);
3510
+ }
3500
3511
  var FEEDBACK_BOOST_TIERS = [
3501
- { minAccuracy: 0.95, minSamples: 20, boost: 5 },
3502
- { minAccuracy: 0.8, minSamples: 5, boost: 2 },
3503
- { minAccuracy: 0.6, minSamples: 5, boost: 0 },
3504
- { minAccuracy: 0.4, minSamples: 5, boost: -2 },
3505
- { minAccuracy: 0, minSamples: 5, boost: -4 }
3512
+ { minAccuracy: 0.85, minSamples: 5, boost: 10 },
3513
+ // Strong reward for high accuracy
3514
+ { minAccuracy: 0.7, minSamples: 3, boost: 6 },
3515
+ // Most TPs land here
3516
+ { minAccuracy: 0.5, minSamples: 3, boost: 0 },
3517
+ // Neutral — Layer 0 suppression handles demotion
3518
+ { minAccuracy: 0.3, minSamples: 3, boost: 0 },
3519
+ // Neutral — no double-penalty with Layer 0
3520
+ { minAccuracy: 0, minSamples: 3, boost: 0 }
3521
+ // Neutral — no double-penalty with Layer 0
3506
3522
  ];
3507
- function recordFeedback(stateDb2, entity, context, notePath, correct) {
3523
+ function recordFeedback(stateDb2, entity, context, notePath, correct, confidence = 1) {
3508
3524
  try {
3509
3525
  console.error(`[Flywheel] recordFeedback: entity="${entity}" context="${context}" notePath="${notePath}" correct=${correct}`);
3510
3526
  const result = stateDb2.db.prepare(
3511
- "INSERT INTO wikilink_feedback (entity, context, note_path, correct) VALUES (?, ?, ?, ?)"
3512
- ).run(entity, context, notePath, correct ? 1 : 0);
3527
+ "INSERT INTO wikilink_feedback (entity, context, note_path, correct, confidence) VALUES (?, ?, ?, ?, ?)"
3528
+ ).run(entity, context, notePath, correct ? 1 : 0, confidence);
3513
3529
  console.error(`[Flywheel] recordFeedback: inserted id=${result.lastInsertRowid}`);
3514
3530
  } catch (e) {
3515
3531
  console.error(`[Flywheel] recordFeedback failed for entity="${entity}": ${e}`);
@@ -3559,16 +3575,91 @@ function getEntityStats(stateDb2) {
3559
3575
  };
3560
3576
  });
3561
3577
  }
3562
- function updateSuppressionList(stateDb2) {
3563
- const stats = stateDb2.db.prepare(`
3564
- SELECT
3578
+ function computeFeedbackWeight(createdAt, now) {
3579
+ const ref = now ?? /* @__PURE__ */ new Date();
3580
+ const normalized = createdAt.includes("T") ? createdAt : createdAt.replace(" ", "T") + "Z";
3581
+ const ageDays = (ref.getTime() - new Date(normalized).getTime()) / (24 * 60 * 60 * 1e3);
3582
+ if (ageDays < 1 / 1440) return 1;
3583
+ return Math.exp(-FEEDBACK_DECAY_LAMBDA * ageDays);
3584
+ }
3585
+ function getWeightedEntityStats(stateDb2, now) {
3586
+ const rows = stateDb2.db.prepare(`
3587
+ SELECT entity, correct, confidence, created_at
3588
+ FROM wikilink_feedback
3589
+ ORDER BY entity COLLATE NOCASE
3590
+ `).all();
3591
+ const acc = /* @__PURE__ */ new Map();
3592
+ for (const row of rows) {
3593
+ const key = row.entity.toLowerCase();
3594
+ if (!acc.has(key)) {
3595
+ acc.set(key, { weightedTotal: 0, weightedCorrect: 0, weightedFp: 0, rawTotal: 0 });
3596
+ }
3597
+ const stats = acc.get(key);
3598
+ const recencyWeight = computeFeedbackWeight(row.created_at, now);
3599
+ const weight = recencyWeight * (row.confidence ?? 1);
3600
+ stats.weightedTotal += weight;
3601
+ stats.rawTotal++;
3602
+ if (row.correct === 1) {
3603
+ stats.weightedCorrect += weight;
3604
+ } else {
3605
+ stats.weightedFp += weight;
3606
+ }
3607
+ if (stats.rawTotal === 1 || !acc.has(row.entity)) {
3608
+ }
3609
+ }
3610
+ const entityNames = /* @__PURE__ */ new Map();
3611
+ for (const row of rows) {
3612
+ const key = row.entity.toLowerCase();
3613
+ if (!entityNames.has(key)) entityNames.set(key, row.entity);
3614
+ }
3615
+ const result = [];
3616
+ for (const [key, stats] of acc) {
3617
+ const entity = entityNames.get(key) ?? key;
3618
+ result.push({
3565
3619
  entity,
3566
- COUNT(*) as total,
3567
- SUM(CASE WHEN correct = 0 THEN 1 ELSE 0 END) as false_positives
3620
+ weightedTotal: stats.weightedTotal,
3621
+ weightedCorrect: stats.weightedCorrect,
3622
+ weightedFp: stats.weightedFp,
3623
+ rawTotal: stats.rawTotal,
3624
+ weightedAccuracy: stats.weightedTotal > 0 ? stats.weightedCorrect / stats.weightedTotal : 0,
3625
+ weightedFpRate: stats.weightedTotal > 0 ? stats.weightedFp / stats.weightedTotal : 0
3626
+ });
3627
+ }
3628
+ return result;
3629
+ }
3630
+ function getWeightedFolderStats(stateDb2, entity, folder, now) {
3631
+ const rows = stateDb2.db.prepare(`
3632
+ SELECT correct, confidence, created_at
3568
3633
  FROM wikilink_feedback
3569
- GROUP BY entity COLLATE NOCASE
3570
- HAVING total >= ?
3571
- `).all(MIN_FEEDBACK_COUNT);
3634
+ WHERE entity = ? COLLATE NOCASE AND (
3635
+ CASE WHEN ? = '' THEN note_path NOT LIKE '%/%'
3636
+ ELSE note_path LIKE ? || '/%'
3637
+ END
3638
+ )
3639
+ `).all(entity, folder, folder);
3640
+ let weightedTotal = 0;
3641
+ let weightedCorrect = 0;
3642
+ let weightedFp = 0;
3643
+ for (const row of rows) {
3644
+ const recencyWeight = computeFeedbackWeight(row.created_at, now);
3645
+ const weight = recencyWeight * (row.confidence ?? 1);
3646
+ weightedTotal += weight;
3647
+ if (row.correct === 1) {
3648
+ weightedCorrect += weight;
3649
+ } else {
3650
+ weightedFp += weight;
3651
+ }
3652
+ }
3653
+ return {
3654
+ weightedTotal,
3655
+ weightedFp,
3656
+ weightedAccuracy: weightedTotal > 0 ? weightedCorrect / weightedTotal : 0,
3657
+ weightedFpRate: weightedTotal > 0 ? weightedFp / weightedTotal : 0,
3658
+ rawTotal: rows.length
3659
+ };
3660
+ }
3661
+ function updateSuppressionList(stateDb2, now) {
3662
+ const weightedStats = getWeightedEntityStats(stateDb2, now);
3572
3663
  let updated = 0;
3573
3664
  const upsert = stateDb2.db.prepare(`
3574
3665
  INSERT INTO wikilink_suppressions (entity, false_positive_rate, updated_at)
@@ -3581,10 +3672,18 @@ function updateSuppressionList(stateDb2) {
3581
3672
  "DELETE FROM wikilink_suppressions WHERE entity = ?"
3582
3673
  );
3583
3674
  const transaction = stateDb2.db.transaction(() => {
3584
- for (const stat4 of stats) {
3585
- const fpRate = stat4.false_positives / stat4.total;
3586
- if (fpRate >= SUPPRESSION_THRESHOLD) {
3587
- upsert.run(stat4.entity, fpRate);
3675
+ stateDb2.db.prepare(
3676
+ `DELETE FROM wikilink_suppressions
3677
+ WHERE datetime(updated_at, '+' || ? || ' days') <= datetime('now')`
3678
+ ).run(SUPPRESSION_TTL_DAYS);
3679
+ for (const stat4 of weightedStats) {
3680
+ const posteriorMean = computePosteriorMean(stat4.weightedCorrect, stat4.weightedFp);
3681
+ const totalObs = PRIOR_ALPHA + stat4.weightedCorrect + PRIOR_BETA + stat4.weightedFp;
3682
+ if (totalObs < SUPPRESSION_MIN_OBSERVATIONS) {
3683
+ continue;
3684
+ }
3685
+ if (posteriorMean < SUPPRESSION_POSTERIOR_THRESHOLD) {
3686
+ upsert.run(stat4.entity, 1 - posteriorMean);
3588
3687
  updated++;
3589
3688
  } else {
3590
3689
  remove.run(stat4.entity);
@@ -3594,26 +3693,33 @@ function updateSuppressionList(stateDb2) {
3594
3693
  transaction();
3595
3694
  return updated;
3596
3695
  }
3597
- function isSuppressed(stateDb2, entity, folder) {
3696
+ function suppressEntity(stateDb2, entity) {
3697
+ stateDb2.db.prepare(`
3698
+ INSERT INTO wikilink_suppressions (entity, false_positive_rate, updated_at)
3699
+ VALUES (?, 1.0, datetime('now'))
3700
+ ON CONFLICT(entity) DO UPDATE SET false_positive_rate = 1.0, updated_at = datetime('now')
3701
+ `).run(entity);
3702
+ }
3703
+ function unsuppressEntity(stateDb2, entity) {
3704
+ const result = stateDb2.db.prepare(
3705
+ "DELETE FROM wikilink_suppressions WHERE entity = ? COLLATE NOCASE"
3706
+ ).run(entity);
3707
+ return result.changes > 0;
3708
+ }
3709
+ function isSuppressed(stateDb2, entity, folder, now) {
3598
3710
  const row = stateDb2.db.prepare(
3599
- "SELECT entity FROM wikilink_suppressions WHERE entity = ? COLLATE NOCASE"
3600
- ).get(entity);
3711
+ `SELECT entity, updated_at FROM wikilink_suppressions
3712
+ WHERE entity = ? COLLATE NOCASE
3713
+ AND datetime(updated_at, '+' || ? || ' days') > datetime('now')`
3714
+ ).get(entity, SUPPRESSION_TTL_DAYS);
3601
3715
  if (row) return true;
3602
3716
  if (folder !== void 0) {
3603
- const folderStats = stateDb2.db.prepare(`
3604
- SELECT
3605
- COUNT(*) as total,
3606
- SUM(CASE WHEN correct = 0 THEN 1 ELSE 0 END) as false_positives
3607
- FROM wikilink_feedback
3608
- WHERE entity = ? COLLATE NOCASE AND (
3609
- CASE WHEN ? = '' THEN note_path NOT LIKE '%/%'
3610
- ELSE note_path LIKE ? || '/%'
3611
- END
3612
- )
3613
- `).get(entity, folder, folder);
3614
- if (folderStats && folderStats.total >= FOLDER_SUPPRESSION_MIN_COUNT) {
3615
- const fpRate = folderStats.false_positives / folderStats.total;
3616
- if (fpRate >= SUPPRESSION_THRESHOLD) {
3717
+ const stats = getWeightedFolderStats(stateDb2, entity, folder, now);
3718
+ if (stats.rawTotal >= FOLDER_SUPPRESSION_MIN_COUNT) {
3719
+ const folderCorrect = stats.weightedTotal - stats.weightedFp;
3720
+ const posteriorMean = computePosteriorMean(folderCorrect, stats.weightedFp);
3721
+ const totalObs = PRIOR_ALPHA + folderCorrect + PRIOR_BETA + stats.weightedFp;
3722
+ if (totalObs >= SUPPRESSION_MIN_OBSERVATIONS && posteriorMean < SUPPRESSION_POSTERIOR_THRESHOLD) {
3617
3723
  return true;
3618
3724
  }
3619
3725
  }
@@ -3643,59 +3749,65 @@ function computeBoostFromAccuracy(accuracy, sampleCount) {
3643
3749
  }
3644
3750
  return 0;
3645
3751
  }
3646
- function getAllFeedbackBoosts(stateDb2, folder) {
3647
- const globalRows = stateDb2.db.prepare(`
3648
- SELECT
3649
- entity,
3650
- COUNT(*) as total,
3651
- SUM(CASE WHEN correct = 1 THEN 1 ELSE 0 END) as correct_count
3652
- FROM wikilink_feedback
3653
- GROUP BY entity
3654
- HAVING total >= ?
3655
- `).all(FEEDBACK_BOOST_MIN_SAMPLES);
3656
- let folderStats = null;
3752
+ function getAllFeedbackBoosts(stateDb2, folder, now) {
3753
+ const globalStats = getWeightedEntityStats(stateDb2, now);
3754
+ let folderStatsMap = null;
3657
3755
  if (folder !== void 0) {
3658
- const folderRows = stateDb2.db.prepare(`
3659
- SELECT
3660
- entity,
3661
- COUNT(*) as total,
3662
- SUM(CASE WHEN correct = 1 THEN 1 ELSE 0 END) as correct_count
3663
- FROM wikilink_feedback
3664
- WHERE (
3665
- CASE WHEN ? = '' THEN note_path NOT LIKE '%/%'
3666
- ELSE note_path LIKE ? || '/%'
3667
- END
3668
- )
3669
- GROUP BY entity
3670
- HAVING total >= ?
3671
- `).all(folder, folder, FEEDBACK_BOOST_MIN_SAMPLES);
3672
- folderStats = /* @__PURE__ */ new Map();
3673
- for (const row of folderRows) {
3674
- folderStats.set(row.entity, {
3675
- accuracy: row.correct_count / row.total,
3676
- count: row.total
3677
- });
3756
+ folderStatsMap = /* @__PURE__ */ new Map();
3757
+ for (const gs of globalStats) {
3758
+ const fs32 = getWeightedFolderStats(stateDb2, gs.entity, folder, now);
3759
+ if (fs32.rawTotal >= FEEDBACK_BOOST_MIN_SAMPLES) {
3760
+ folderStatsMap.set(gs.entity, {
3761
+ weightedAccuracy: fs32.weightedAccuracy,
3762
+ rawCount: fs32.rawTotal
3763
+ });
3764
+ }
3678
3765
  }
3679
3766
  }
3680
3767
  const boosts = /* @__PURE__ */ new Map();
3681
- for (const row of globalRows) {
3768
+ for (const stat4 of globalStats) {
3769
+ if (stat4.rawTotal < FEEDBACK_BOOST_MIN_SAMPLES) continue;
3682
3770
  let accuracy;
3683
3771
  let sampleCount;
3684
- const fs32 = folderStats?.get(row.entity);
3685
- if (fs32 && fs32.count >= FEEDBACK_BOOST_MIN_SAMPLES) {
3686
- accuracy = fs32.accuracy;
3687
- sampleCount = fs32.count;
3772
+ const fs32 = folderStatsMap?.get(stat4.entity);
3773
+ if (fs32 && fs32.rawCount >= FEEDBACK_BOOST_MIN_SAMPLES) {
3774
+ accuracy = fs32.weightedAccuracy;
3775
+ sampleCount = fs32.rawCount;
3688
3776
  } else {
3689
- accuracy = row.correct_count / row.total;
3690
- sampleCount = row.total;
3777
+ accuracy = stat4.weightedAccuracy;
3778
+ sampleCount = stat4.rawTotal;
3691
3779
  }
3692
3780
  const boost = computeBoostFromAccuracy(accuracy, sampleCount);
3693
3781
  if (boost !== 0) {
3694
- boosts.set(row.entity, boost);
3782
+ boosts.set(stat4.entity, boost);
3695
3783
  }
3696
3784
  }
3697
3785
  return boosts;
3698
3786
  }
3787
+ function getAllSuppressionPenalties(stateDb2, now) {
3788
+ const penalties = /* @__PURE__ */ new Map();
3789
+ const weightedStats = getWeightedEntityStats(stateDb2, now);
3790
+ for (const stat4 of weightedStats) {
3791
+ const posteriorMean = computePosteriorMean(stat4.weightedCorrect, stat4.weightedFp);
3792
+ const totalObs = PRIOR_ALPHA + stat4.weightedCorrect + PRIOR_BETA + stat4.weightedFp;
3793
+ if (totalObs >= SUPPRESSION_MIN_OBSERVATIONS && posteriorMean < SUPPRESSION_POSTERIOR_THRESHOLD) {
3794
+ const penalty = Math.round(MAX_SUPPRESSION_PENALTY * (1 - posteriorMean / SUPPRESSION_POSTERIOR_THRESHOLD));
3795
+ if (penalty < 0) {
3796
+ penalties.set(stat4.entity, penalty);
3797
+ }
3798
+ }
3799
+ }
3800
+ const rows = stateDb2.db.prepare(
3801
+ `SELECT entity, updated_at FROM wikilink_suppressions
3802
+ WHERE datetime(updated_at, '+' || ? || ' days') > datetime('now')`
3803
+ ).all(SUPPRESSION_TTL_DAYS);
3804
+ for (const row of rows) {
3805
+ if (!penalties.has(row.entity)) {
3806
+ penalties.set(row.entity, MAX_SUPPRESSION_PENALTY);
3807
+ }
3808
+ }
3809
+ return penalties;
3810
+ }
3699
3811
  function trackWikilinkApplications(stateDb2, notePath, entities) {
3700
3812
  const upsert = stateDb2.db.prepare(`
3701
3813
  INSERT INTO wikilink_applications (entity, note_path, applied_at, status)
@@ -3717,6 +3829,18 @@ function getTrackedApplications(stateDb2, notePath) {
3717
3829
  ).all(notePath);
3718
3830
  return rows.map((r) => r.entity);
3719
3831
  }
3832
+ function getTrackedApplicationsWithTime(stateDb2, notePath) {
3833
+ return stateDb2.db.prepare(
3834
+ `SELECT entity, applied_at FROM wikilink_applications WHERE note_path = ? AND status = 'applied'`
3835
+ ).all(notePath);
3836
+ }
3837
+ function computeImplicitRemovalConfidence(appliedAt) {
3838
+ const normalized = appliedAt.includes("T") ? appliedAt : appliedAt.replace(" ", "T") + "Z";
3839
+ const ageHours = (Date.now() - new Date(normalized).getTime()) / (60 * 60 * 1e3);
3840
+ if (ageHours <= 1) return 1;
3841
+ if (ageHours <= 24) return 0.85;
3842
+ return 0.7;
3843
+ }
3720
3844
  function getStoredNoteLinks(stateDb2, notePath) {
3721
3845
  const rows = stateDb2.db.prepare(
3722
3846
  "SELECT target FROM note_links WHERE note_path = ?"
@@ -3770,17 +3894,18 @@ function updateStoredNoteTags(stateDb2, notePath, currentTags) {
3770
3894
  tx();
3771
3895
  }
3772
3896
  function processImplicitFeedback(stateDb2, notePath, currentContent) {
3773
- const tracked = getTrackedApplications(stateDb2, notePath);
3774
- if (tracked.length === 0) return [];
3897
+ const trackedWithTime = getTrackedApplicationsWithTime(stateDb2, notePath);
3898
+ if (trackedWithTime.length === 0) return [];
3775
3899
  const currentLinks = extractLinkedEntities(currentContent);
3776
3900
  const removed = [];
3777
3901
  const markRemoved = stateDb2.db.prepare(
3778
3902
  `UPDATE wikilink_applications SET status = 'removed' WHERE entity = ? AND note_path = ?`
3779
3903
  );
3780
3904
  const transaction = stateDb2.db.transaction(() => {
3781
- for (const entity of tracked) {
3905
+ for (const { entity, applied_at } of trackedWithTime) {
3782
3906
  if (!currentLinks.has(entity.toLowerCase())) {
3783
- recordFeedback(stateDb2, entity, "implicit:removed", notePath, false);
3907
+ const confidence = computeImplicitRemovalConfidence(applied_at);
3908
+ recordFeedback(stateDb2, entity, "implicit:removed", notePath, false, confidence);
3784
3909
  markRemoved.run(entity, notePath);
3785
3910
  removed.push(entity);
3786
3911
  }
@@ -3793,11 +3918,11 @@ function processImplicitFeedback(stateDb2, notePath, currentContent) {
3793
3918
  return removed;
3794
3919
  }
3795
3920
  var TIER_LABELS = [
3796
- { label: "Champion (+5)", boost: 5, minAccuracy: 0.95, minSamples: 20 },
3797
- { label: "Strong (+2)", boost: 2, minAccuracy: 0.8, minSamples: 5 },
3798
- { label: "Neutral (0)", boost: 0, minAccuracy: 0.6, minSamples: 5 },
3799
- { label: "Weak (-2)", boost: -2, minAccuracy: 0.4, minSamples: 5 },
3800
- { label: "Poor (-4)", boost: -4, minAccuracy: 0, minSamples: 5 }
3921
+ { label: "Champion (+10)", boost: 10, minAccuracy: 0.85, minSamples: 5 },
3922
+ { label: "Strong (+6)", boost: 6, minAccuracy: 0.7, minSamples: 3 },
3923
+ { label: "Neutral (0)", boost: 0, minAccuracy: 0.5, minSamples: 3 },
3924
+ { label: "Neutral (0)", boost: 0, minAccuracy: 0.3, minSamples: 3 },
3925
+ { label: "Neutral (0)", boost: 0, minAccuracy: 0, minSamples: 3 }
3801
3926
  ];
3802
3927
  function getDashboardData(stateDb2) {
3803
3928
  const entityStats = getEntityStats(stateDb2);
@@ -5258,14 +5383,14 @@ function stem(word) {
5258
5383
  }
5259
5384
  function tokenize(text) {
5260
5385
  const cleanText = text.replace(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g, "$1").replace(/[*_`#\[\]()]/g, " ").toLowerCase();
5261
- const words = cleanText.match(/\b[a-z]{3,}\b/g) || [];
5386
+ const words = cleanText.match(/\b[a-z][a-z0-9]{2,}\b/g) || [];
5262
5387
  return words.filter((word) => !STOPWORDS.has(word));
5263
5388
  }
5264
5389
 
5265
5390
  // src/core/shared/cooccurrence.ts
5266
5391
  import { readdir as readdir2, readFile as readFile2 } from "fs/promises";
5267
5392
  import path10 from "path";
5268
- var DEFAULT_MIN_COOCCURRENCE = 2;
5393
+ var DEFAULT_MIN_COOCCURRENCE = 0.5;
5269
5394
  var EXCLUDED_FOLDERS2 = /* @__PURE__ */ new Set([
5270
5395
  "templates",
5271
5396
  ".obsidian",
@@ -5287,12 +5412,12 @@ function noteContainsEntity(content, entityName) {
5287
5412
  }
5288
5413
  return matchCount / entityTokens.length >= 0.5;
5289
5414
  }
5290
- function incrementCooccurrence(associations, entityA, entityB) {
5415
+ function incrementCooccurrence(associations, entityA, entityB, weight = 1) {
5291
5416
  if (!associations[entityA]) {
5292
5417
  associations[entityA] = /* @__PURE__ */ new Map();
5293
5418
  }
5294
5419
  const current = associations[entityA].get(entityB) || 0;
5295
- associations[entityA].set(entityB, current + 1);
5420
+ associations[entityA].set(entityB, current + weight);
5296
5421
  }
5297
5422
  async function* walkMarkdownFiles2(dir, baseDir) {
5298
5423
  try {
@@ -5316,6 +5441,7 @@ async function* walkMarkdownFiles2(dir, baseDir) {
5316
5441
  async function mineCooccurrences(vaultPath2, entities, options = {}) {
5317
5442
  const { minCount = DEFAULT_MIN_COOCCURRENCE } = options;
5318
5443
  const associations = {};
5444
+ const documentFrequency = /* @__PURE__ */ new Map();
5319
5445
  let notesScanned = 0;
5320
5446
  const validEntities = entities.filter((e) => e.length <= 30);
5321
5447
  for await (const file of walkMarkdownFiles2(vaultPath2, vaultPath2)) {
@@ -5328,10 +5454,15 @@ async function mineCooccurrences(vaultPath2, entities, options = {}) {
5328
5454
  mentionedEntities.push(entity);
5329
5455
  }
5330
5456
  }
5457
+ for (const entity of mentionedEntities) {
5458
+ documentFrequency.set(entity, (documentFrequency.get(entity) || 0) + 1);
5459
+ }
5460
+ const degree = mentionedEntities.length;
5461
+ const adamicAdarWeight = degree >= 3 ? 1 / Math.log(degree) : 1;
5331
5462
  for (const entityA of mentionedEntities) {
5332
5463
  for (const entityB of mentionedEntities) {
5333
5464
  if (entityA !== entityB) {
5334
- incrementCooccurrence(associations, entityA, entityB);
5465
+ incrementCooccurrence(associations, entityA, entityB, adamicAdarWeight);
5335
5466
  }
5336
5467
  }
5337
5468
  }
@@ -5349,6 +5480,8 @@ async function mineCooccurrences(vaultPath2, entities, options = {}) {
5349
5480
  return {
5350
5481
  associations,
5351
5482
  minCount,
5483
+ documentFrequency,
5484
+ totalNotesScanned: notesScanned,
5352
5485
  _metadata: {
5353
5486
  generated_at: (/* @__PURE__ */ new Date()).toISOString(),
5354
5487
  total_associations: totalAssociations,
@@ -5356,26 +5489,50 @@ async function mineCooccurrences(vaultPath2, entities, options = {}) {
5356
5489
  }
5357
5490
  };
5358
5491
  }
5359
- var MAX_COOCCURRENCE_BOOST = 6;
5492
+ var MAX_COOCCURRENCE_BOOST = 12;
5493
+ var PMI_SCALE = 12;
5494
+ function computeNpmi(coocCount, dfEntity, dfSeed, totalNotes) {
5495
+ if (coocCount === 0 || dfEntity === 0 || dfSeed === 0 || totalNotes === 0) return 0;
5496
+ const pxy = coocCount / totalNotes;
5497
+ const px = dfEntity / totalNotes;
5498
+ const py = dfSeed / totalNotes;
5499
+ const pmi = Math.log(pxy / (px * py));
5500
+ const negLogPxy = -Math.log(pxy);
5501
+ if (negLogPxy === 0) return 1;
5502
+ const npmi = pmi / negLogPxy;
5503
+ return Math.max(0, Math.min(1, npmi));
5504
+ }
5360
5505
  function getCooccurrenceBoost(entityName, matchedEntities, cooccurrenceIndex2, recencyIndex2) {
5361
5506
  if (!cooccurrenceIndex2) return 0;
5362
- let boost = 0;
5363
- const { associations, minCount } = cooccurrenceIndex2;
5507
+ const { associations, minCount, documentFrequency, totalNotesScanned } = cooccurrenceIndex2;
5508
+ const dfEntity = documentFrequency.get(entityName) || 0;
5509
+ if (dfEntity === 0 || totalNotesScanned === 0) return 0;
5510
+ let bestNpmi = 0;
5364
5511
  for (const matched of matchedEntities) {
5365
5512
  const entityAssocs = associations[matched];
5366
- if (entityAssocs) {
5367
- const count = entityAssocs.get(entityName) || 0;
5368
- if (count >= minCount) {
5369
- boost += 3;
5370
- }
5371
- }
5372
- }
5373
- if (boost > 0 && recencyIndex2) {
5513
+ if (!entityAssocs) continue;
5514
+ const coocCount = entityAssocs.get(entityName) || 0;
5515
+ if (coocCount < minCount) continue;
5516
+ const dfSeed = documentFrequency.get(matched) || 0;
5517
+ const npmi = computeNpmi(coocCount, dfEntity, dfSeed, totalNotesScanned);
5518
+ bestNpmi = Math.max(bestNpmi, npmi);
5519
+ }
5520
+ if (bestNpmi === 0) return 0;
5521
+ let boost = bestNpmi * PMI_SCALE;
5522
+ if (recencyIndex2) {
5374
5523
  const recencyBoostVal = getRecencyBoost(entityName, recencyIndex2);
5375
5524
  const recencyMultiplier = recencyBoostVal > 0 ? 1.5 : 0.5;
5376
- boost = Math.round(boost * recencyMultiplier);
5525
+ boost = boost * recencyMultiplier;
5377
5526
  }
5378
- return Math.min(boost, MAX_COOCCURRENCE_BOOST);
5527
+ return Math.min(Math.round(boost), MAX_COOCCURRENCE_BOOST);
5528
+ }
5529
+ function tokenIdf(token, coocIndex) {
5530
+ if (!coocIndex || coocIndex.totalNotesScanned === 0) return 1;
5531
+ const df = coocIndex.documentFrequency.get(token);
5532
+ if (df === void 0) return 1;
5533
+ const N = coocIndex.totalNotesScanned;
5534
+ const rawIdf = Math.log((N + 1) / (df + 1));
5535
+ return Math.max(0.5, Math.min(2.5, rawIdf));
5379
5536
  }
5380
5537
 
5381
5538
  // src/core/write/edgeWeights.ts
@@ -5951,8 +6108,8 @@ function isLikelyArticleTitle(name) {
5951
6108
  var STRICTNESS_CONFIGS = {
5952
6109
  conservative: {
5953
6110
  minWordLength: 3,
5954
- minSuggestionScore: 15,
5955
- // Requires exact match (10) + at least one stem (5)
6111
+ minSuggestionScore: 18,
6112
+ // Requires exact match (10) + stem (5) + at least one boost
5956
6113
  minMatchRatio: 0.6,
5957
6114
  // 60% of multi-word entity must match
5958
6115
  requireMultipleMatches: true,
@@ -5964,8 +6121,8 @@ var STRICTNESS_CONFIGS = {
5964
6121
  },
5965
6122
  balanced: {
5966
6123
  minWordLength: 3,
5967
- minSuggestionScore: 8,
5968
- // At least one exact match or two stem matches
6124
+ minSuggestionScore: 10,
6125
+ // Exact match (10) or two stem matches
5969
6126
  minMatchRatio: 0.4,
5970
6127
  // 40% of multi-word entity must match
5971
6128
  requireMultipleMatches: false,
@@ -6112,7 +6269,7 @@ function getAdaptiveMinScore(contentLength, baseScore) {
6112
6269
  var MIN_SUGGESTION_SCORE = STRICTNESS_CONFIGS.balanced.minSuggestionScore;
6113
6270
  var MIN_MATCH_RATIO = STRICTNESS_CONFIGS.balanced.minMatchRatio;
6114
6271
  var FULL_ALIAS_MATCH_BONUS = 8;
6115
- function scoreNameAgainstContent(name, contentTokens, contentStems, config) {
6272
+ function scoreNameAgainstContent(name, contentTokens, contentStems, config, coocIndex) {
6116
6273
  const nameTokens = tokenize(name);
6117
6274
  if (nameTokens.length === 0) {
6118
6275
  return { score: 0, matchedWords: 0, exactMatches: 0, totalTokens: 0 };
@@ -6124,24 +6281,26 @@ function scoreNameAgainstContent(name, contentTokens, contentStems, config) {
6124
6281
  for (let i = 0; i < nameTokens.length; i++) {
6125
6282
  const token = nameTokens[i];
6126
6283
  const nameStem = nameStems[i];
6284
+ const idfWeight = coocIndex ? tokenIdf(token, coocIndex) : 1;
6127
6285
  if (contentTokens.has(token)) {
6128
- score += config.exactMatchBonus;
6286
+ score += config.exactMatchBonus * idfWeight;
6129
6287
  matchedWords++;
6130
6288
  exactMatches++;
6131
6289
  } else if (contentStems.has(nameStem)) {
6132
- score += config.stemMatchBonus;
6290
+ score += config.stemMatchBonus * idfWeight;
6133
6291
  matchedWords++;
6134
6292
  }
6135
6293
  }
6294
+ score = Math.round(score * 10) / 10;
6136
6295
  return { score, matchedWords, exactMatches, totalTokens: nameTokens.length };
6137
6296
  }
6138
- function scoreEntity(entity, contentTokens, contentStems, config) {
6297
+ function scoreEntity(entity, contentTokens, contentStems, config, coocIndex) {
6139
6298
  const entityName = getEntityName2(entity);
6140
6299
  const aliases = getEntityAliases(entity);
6141
- const nameResult = scoreNameAgainstContent(entityName, contentTokens, contentStems, config);
6300
+ const nameResult = scoreNameAgainstContent(entityName, contentTokens, contentStems, config, coocIndex);
6142
6301
  let bestAliasResult = { score: 0, matchedWords: 0, exactMatches: 0, totalTokens: 0 };
6143
6302
  for (const alias of aliases) {
6144
- const aliasResult = scoreNameAgainstContent(alias, contentTokens, contentStems, config);
6303
+ const aliasResult = scoreNameAgainstContent(alias, contentTokens, contentStems, config, coocIndex);
6145
6304
  if (aliasResult.score > bestAliasResult.score) {
6146
6305
  bestAliasResult = aliasResult;
6147
6306
  }
@@ -6151,7 +6310,7 @@ function scoreEntity(entity, contentTokens, contentStems, config) {
6151
6310
  if (totalTokens === 0) return 0;
6152
6311
  for (const alias of aliases) {
6153
6312
  const aliasLower = alias.toLowerCase();
6154
- if (aliasLower.length >= 4 && !/\s/.test(aliasLower) && contentTokens.has(aliasLower)) {
6313
+ if (aliasLower.length >= 3 && !/\s/.test(aliasLower) && contentTokens.has(aliasLower)) {
6155
6314
  score += FULL_ALIAS_MATCH_BONUS;
6156
6315
  break;
6157
6316
  }
@@ -6176,7 +6335,7 @@ function getEdgeWeightBoostScore(entityName, map) {
6176
6335
  }
6177
6336
  async function suggestRelatedLinks(content, options = {}) {
6178
6337
  const {
6179
- maxSuggestions = 3,
6338
+ maxSuggestions = 8,
6180
6339
  excludeLinked = true,
6181
6340
  strictness = getEffectiveStrictness(options.notePath),
6182
6341
  notePath,
@@ -6218,6 +6377,7 @@ async function suggestRelatedLinks(content, options = {}) {
6218
6377
  const linkedEntities = excludeLinked ? extractLinkedEntities(content) : /* @__PURE__ */ new Set();
6219
6378
  const noteFolder = notePath ? notePath.split("/")[0] : void 0;
6220
6379
  const feedbackBoosts = moduleStateDb5 ? getAllFeedbackBoosts(moduleStateDb5, noteFolder) : /* @__PURE__ */ new Map();
6380
+ const suppressionPenalties = moduleStateDb5 ? getAllSuppressionPenalties(moduleStateDb5) : /* @__PURE__ */ new Map();
6221
6381
  const edgeWeightMap = moduleStateDb5 ? getEntityEdgeWeightMap(moduleStateDb5) : /* @__PURE__ */ new Map();
6222
6382
  const scoredEntities = [];
6223
6383
  const directlyMatchedEntities = /* @__PURE__ */ new Set();
@@ -6234,13 +6394,7 @@ async function suggestRelatedLinks(content, options = {}) {
6234
6394
  if (linkedEntities.has(entityName.toLowerCase())) {
6235
6395
  continue;
6236
6396
  }
6237
- if (moduleStateDb5 && !disabled.has("feedback")) {
6238
- const noteFolder2 = notePath ? notePath.split("/").slice(0, -1).join("/") : void 0;
6239
- if (isSuppressed(moduleStateDb5, entityName, noteFolder2)) {
6240
- continue;
6241
- }
6242
- }
6243
- const contentScore = disabled.has("exact_match") && disabled.has("stem_match") ? 0 : scoreEntity(entity, contentTokens, contentStems, config);
6397
+ const contentScore = disabled.has("exact_match") && disabled.has("stem_match") ? 0 : scoreEntity(entity, contentTokens, contentStems, config, cooccurrenceIndex);
6244
6398
  let score = contentScore;
6245
6399
  if (contentScore > 0) {
6246
6400
  entitiesWithContentMatch.add(entityName);
@@ -6259,10 +6413,12 @@ async function suggestRelatedLinks(content, options = {}) {
6259
6413
  score += layerFeedbackAdj;
6260
6414
  const layerEdgeWeightBoost = disabled.has("edge_weight") ? 0 : getEdgeWeightBoostScore(entityName, edgeWeightMap);
6261
6415
  score += layerEdgeWeightBoost;
6262
- if (score > 0) {
6416
+ if (contentScore > 0) {
6263
6417
  directlyMatchedEntities.add(entityName);
6264
6418
  }
6265
- if (score >= adaptiveMinScore) {
6419
+ const layerSuppressionPenalty = disabled.has("feedback") ? 0 : suppressionPenalties.get(entityName) ?? 0;
6420
+ score += layerSuppressionPenalty;
6421
+ if (contentScore > 0 && score >= adaptiveMinScore) {
6266
6422
  scoredEntities.push({
6267
6423
  name: entityName,
6268
6424
  path: entity.path || "",
@@ -6277,23 +6433,31 @@ async function suggestRelatedLinks(content, options = {}) {
6277
6433
  crossFolderBoost: layerCrossFolderBoost,
6278
6434
  hubBoost: layerHubBoost,
6279
6435
  feedbackAdjustment: layerFeedbackAdj,
6436
+ suppressionPenalty: layerSuppressionPenalty,
6280
6437
  edgeWeightBoost: layerEdgeWeightBoost
6281
6438
  }
6282
6439
  });
6283
6440
  }
6284
6441
  }
6285
- if (!disabled.has("cooccurrence") && cooccurrenceIndex && directlyMatchedEntities.size > 0) {
6442
+ const cooccurrenceSeeds = new Set(directlyMatchedEntities);
6443
+ if (linkedEntities.size > 0) {
6444
+ const lowerToDisplay = /* @__PURE__ */ new Map();
6445
+ for (const { entity } of entitiesWithTypes) {
6446
+ if (entity.name) lowerToDisplay.set(entity.name.toLowerCase(), entity.name);
6447
+ }
6448
+ for (const linked of linkedEntities) {
6449
+ const displayName = lowerToDisplay.get(linked);
6450
+ if (displayName) cooccurrenceSeeds.add(displayName);
6451
+ }
6452
+ }
6453
+ if (!disabled.has("cooccurrence") && cooccurrenceIndex && cooccurrenceSeeds.size > 0) {
6286
6454
  for (const { entity, category } of entitiesWithTypes) {
6287
6455
  const entityName = entity.name;
6288
6456
  if (!entityName) continue;
6289
6457
  if (!disabled.has("length_filter") && entityName.length > MAX_ENTITY_LENGTH) continue;
6290
6458
  if (!disabled.has("article_filter") && isLikelyArticleTitle(entityName)) continue;
6291
6459
  if (linkedEntities.has(entityName.toLowerCase())) continue;
6292
- if (moduleStateDb5 && !disabled.has("feedback")) {
6293
- const noteFolder2 = notePath ? notePath.split("/").slice(0, -1).join("/") : void 0;
6294
- if (isSuppressed(moduleStateDb5, entityName, noteFolder2)) continue;
6295
- }
6296
- const boost = getCooccurrenceBoost(entityName, directlyMatchedEntities, cooccurrenceIndex, recencyIndex);
6460
+ const boost = getCooccurrenceBoost(entityName, cooccurrenceSeeds, cooccurrenceIndex, recencyIndex);
6297
6461
  if (boost > 0) {
6298
6462
  const existing = scoredEntities.find((e) => e.name === entityName);
6299
6463
  if (existing) {
@@ -6304,19 +6468,24 @@ async function suggestRelatedLinks(content, options = {}) {
6304
6468
  const hasContentOverlap = entityTokens.some(
6305
6469
  (token) => contentTokens.has(token) || contentStems.has(stem(token))
6306
6470
  );
6307
- if (!hasContentOverlap) {
6471
+ const strongCooccurrence = boost >= 4;
6472
+ if (!hasContentOverlap && !strongCooccurrence) {
6308
6473
  continue;
6309
6474
  }
6310
- entitiesWithContentMatch.add(entityName);
6475
+ if (hasContentOverlap || strongCooccurrence) {
6476
+ entitiesWithContentMatch.add(entityName);
6477
+ }
6311
6478
  const typeBoost = disabled.has("type_boost") ? 0 : TYPE_BOOST[category] || 0;
6312
6479
  const contextBoost = disabled.has("context_boost") ? 0 : contextBoosts[category] || 0;
6313
- const recencyBoostVal = disabled.has("recency") ? 0 : recencyIndex ? getRecencyBoost(entityName, recencyIndex) : 0;
6480
+ const recencyBoostVal = hasContentOverlap && !disabled.has("recency") ? recencyIndex ? getRecencyBoost(entityName, recencyIndex) : 0 : 0;
6314
6481
  const crossFolderBoost = disabled.has("cross_folder") ? 0 : notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
6315
6482
  const hubBoost = disabled.has("hub_boost") ? 0 : getHubBoost(entity);
6316
6483
  const feedbackAdj = disabled.has("feedback") ? 0 : feedbackBoosts.get(entityName) ?? 0;
6317
6484
  const edgeWeightBoost = disabled.has("edge_weight") ? 0 : getEdgeWeightBoostScore(entityName, edgeWeightMap);
6318
- const totalBoost = boost + typeBoost + contextBoost + recencyBoostVal + crossFolderBoost + hubBoost + feedbackAdj + edgeWeightBoost;
6319
- if (totalBoost >= adaptiveMinScore) {
6485
+ const suppPenalty = disabled.has("feedback") ? 0 : suppressionPenalties.get(entityName) ?? 0;
6486
+ const totalBoost = boost + typeBoost + contextBoost + recencyBoostVal + crossFolderBoost + hubBoost + feedbackAdj + edgeWeightBoost + suppPenalty;
6487
+ const effectiveMinScore = !hasContentOverlap ? Math.max(adaptiveMinScore, 10) : adaptiveMinScore;
6488
+ if (totalBoost >= effectiveMinScore) {
6320
6489
  scoredEntities.push({
6321
6490
  name: entityName,
6322
6491
  path: entity.path || "",
@@ -6331,6 +6500,7 @@ async function suggestRelatedLinks(content, options = {}) {
6331
6500
  crossFolderBoost,
6332
6501
  hubBoost,
6333
6502
  feedbackAdjustment: feedbackAdj,
6503
+ suppressionPenalty: suppPenalty,
6334
6504
  edgeWeightBoost
6335
6505
  }
6336
6506
  });
@@ -6357,10 +6527,6 @@ async function suggestRelatedLinks(content, options = {}) {
6357
6527
  existing.score += boost;
6358
6528
  existing.breakdown.semanticBoost = boost;
6359
6529
  } else if (!linkedEntities.has(match.entityName.toLowerCase())) {
6360
- if (moduleStateDb5 && !disabled.has("feedback")) {
6361
- const noteFolder2 = notePath ? notePath.split("/").slice(0, -1).join("/") : void 0;
6362
- if (isSuppressed(moduleStateDb5, match.entityName, noteFolder2)) continue;
6363
- }
6364
6530
  const entityWithType = entitiesWithTypes.find(
6365
6531
  (et) => et.entity.name === match.entityName
6366
6532
  );
@@ -6374,7 +6540,8 @@ async function suggestRelatedLinks(content, options = {}) {
6374
6540
  const layerCrossFolderBoost = disabled.has("cross_folder") ? 0 : notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
6375
6541
  const layerFeedbackAdj = disabled.has("feedback") ? 0 : feedbackBoosts.get(match.entityName) ?? 0;
6376
6542
  const layerEdgeWeightBoost = disabled.has("edge_weight") ? 0 : getEdgeWeightBoostScore(match.entityName, edgeWeightMap);
6377
- const totalScore = boost + layerTypeBoost + layerContextBoost + layerHubBoost + layerCrossFolderBoost + layerFeedbackAdj + layerEdgeWeightBoost;
6543
+ const layerSuppPenalty = disabled.has("feedback") ? 0 : suppressionPenalties.get(match.entityName) ?? 0;
6544
+ const totalScore = boost + layerTypeBoost + layerContextBoost + layerHubBoost + layerCrossFolderBoost + layerFeedbackAdj + layerEdgeWeightBoost + layerSuppPenalty;
6378
6545
  if (totalScore >= adaptiveMinScore) {
6379
6546
  scoredEntities.push({
6380
6547
  name: match.entityName,
@@ -6390,6 +6557,7 @@ async function suggestRelatedLinks(content, options = {}) {
6390
6557
  crossFolderBoost: layerCrossFolderBoost,
6391
6558
  hubBoost: layerHubBoost,
6392
6559
  feedbackAdjustment: layerFeedbackAdj,
6560
+ suppressionPenalty: layerSuppPenalty,
6393
6561
  semanticBoost: boost,
6394
6562
  edgeWeightBoost: layerEdgeWeightBoost
6395
6563
  }
@@ -7846,11 +8014,12 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
7846
8014
  text: z2.string().describe("The text to analyze for potential wikilinks"),
7847
8015
  limit: z2.coerce.number().default(50).describe("Maximum number of suggestions to return"),
7848
8016
  offset: z2.coerce.number().default(0).describe("Number of suggestions to skip (for pagination)"),
7849
- detail: z2.boolean().default(false).describe("Include per-layer score breakdown for each suggestion")
8017
+ detail: z2.boolean().default(false).describe("Include per-layer score breakdown for each suggestion"),
8018
+ note_path: z2.string().optional().describe("Path of the note being analyzed (enables strictness override for daily notes)")
7850
8019
  },
7851
8020
  outputSchema: SuggestWikilinksOutputSchema
7852
8021
  },
7853
- async ({ text, limit: requestedLimit, offset, detail }) => {
8022
+ async ({ text, limit: requestedLimit, offset, detail, note_path }) => {
7854
8023
  const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
7855
8024
  const index = getIndex();
7856
8025
  const allMatches = findEntityMatches(text, index.entities);
@@ -7865,7 +8034,8 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
7865
8034
  const scored = await suggestRelatedLinks(text, {
7866
8035
  detail: true,
7867
8036
  maxSuggestions: limit,
7868
- strictness: "balanced"
8037
+ strictness: "balanced",
8038
+ ...note_path ? { notePath: note_path } : {}
7869
8039
  });
7870
8040
  if (scored.detailed) {
7871
8041
  output.scored_suggestions = scored.detailed;
@@ -12888,7 +13058,7 @@ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", con
12888
13058
  preserveListNesting: z11.boolean().default(true).describe("Detect and preserve the indentation level of surrounding list items. Set false to disable."),
12889
13059
  bumpHeadings: z11.boolean().default(true).describe("Auto-bump heading levels in inserted content so they nest under the target section (e.g., ## in a ## section becomes ###). Set false to disable."),
12890
13060
  suggestOutgoingLinks: z11.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]], [[Philosophy]]"). Set false to disable.'),
12891
- maxSuggestions: z11.number().min(1).max(10).default(3).describe("Maximum number of suggested wikilinks to append (1-10, default: 3)"),
13061
+ maxSuggestions: z11.number().min(1).max(10).default(5).describe("Maximum number of suggested wikilinks to append (1-10, default: 5)"),
12892
13062
  validate: z11.boolean().default(true).describe("Check input for common issues (double timestamps, non-markdown bullets, etc.)"),
12893
13063
  normalize: z11.boolean().default(true).describe("Auto-fix common issues before formatting (replace \u2022 with -, trim excessive whitespace, etc.)"),
12894
13064
  guardrails: z11.enum(["warn", "strict", "off"]).default("warn").describe('Output validation mode: "warn" returns issues but proceeds, "strict" blocks on errors, "off" disables'),
@@ -13034,7 +13204,7 @@ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", con
13034
13204
  commit: z11.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
13035
13205
  skipWikilinks: z11.boolean().default(false).describe("If true, skip auto-wikilink application on replacement text"),
13036
13206
  suggestOutgoingLinks: z11.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]], [[Philosophy]]"). Set false to disable.'),
13037
- maxSuggestions: z11.number().min(1).max(10).default(3).describe("Maximum number of suggested wikilinks to append (1-10, default: 3)"),
13207
+ maxSuggestions: z11.number().min(1).max(10).default(5).describe("Maximum number of suggested wikilinks to append (1-10, default: 5)"),
13038
13208
  validate: z11.boolean().default(true).describe("Check input for common issues (double timestamps, non-markdown bullets, etc.)"),
13039
13209
  normalize: z11.boolean().default(true).describe("Auto-fix common issues before formatting (replace \u2022 with -, trim excessive whitespace, etc.)"),
13040
13210
  guardrails: z11.enum(["warn", "strict", "off"]).default("warn").describe('Output validation mode: "warn" returns issues but proceeds, "strict" blocks on errors, "off" disables'),
@@ -13062,7 +13232,7 @@ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", con
13062
13232
  throw new Error(validationResult.blockReason || "Output validation failed");
13063
13233
  }
13064
13234
  let workingReplacement = validationResult.content;
13065
- let { content: processedReplacement } = maybeApplyWikilinks(workingReplacement, skipWikilinks, notePath);
13235
+ let { content: processedReplacement } = maybeApplyWikilinks(workingReplacement, skipWikilinks, notePath, ctx.content);
13066
13236
  if (suggestOutgoingLinks && !skipWikilinks && processedReplacement.length >= 100) {
13067
13237
  const result = await suggestRelatedLinks(processedReplacement, { maxSuggestions, notePath });
13068
13238
  if (result.suffix) {
@@ -13232,7 +13402,7 @@ function registerTaskTools(server2, vaultPath2) {
13232
13402
  commit: z12.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
13233
13403
  skipWikilinks: z12.boolean().default(false).describe("If true, skip auto-wikilink application (wikilinks are applied by default)"),
13234
13404
  suggestOutgoingLinks: z12.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]], [[Philosophy]]"). Set false to disable.'),
13235
- maxSuggestions: z12.number().min(1).max(10).default(3).describe("Maximum number of suggested wikilinks to append (1-10, default: 3)"),
13405
+ maxSuggestions: z12.number().min(1).max(10).default(5).describe("Maximum number of suggested wikilinks to append (1-10, default: 5)"),
13236
13406
  preserveListNesting: z12.boolean().default(true).describe("Preserve indentation when inserting into nested lists. Default: true"),
13237
13407
  validate: z12.boolean().default(true).describe("Check input for common issues"),
13238
13408
  normalize: z12.boolean().default(true).describe("Auto-fix common issues before formatting"),
@@ -13373,7 +13543,7 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
13373
13543
  commit: z14.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
13374
13544
  skipWikilinks: z14.boolean().default(false).describe("If true, skip auto-wikilink application (wikilinks are applied by default)"),
13375
13545
  suggestOutgoingLinks: z14.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]], [[Philosophy]]").'),
13376
- maxSuggestions: z14.number().min(1).max(10).default(3).describe("Maximum number of suggested wikilinks to append (1-10, default: 3)"),
13546
+ maxSuggestions: z14.number().min(1).max(10).default(5).describe("Maximum number of suggested wikilinks to append (1-10, default: 5)"),
13377
13547
  agent_id: z14.string().optional().describe("Agent identifier for multi-agent scoping"),
13378
13548
  session_id: z14.string().optional().describe("Session identifier for conversation scoping")
13379
13549
  },
@@ -15857,7 +16027,7 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
15857
16027
  title: "Wikilink Feedback",
15858
16028
  description: 'Report and query wikilink accuracy feedback. Modes: "report" (record feedback), "list" (view recent feedback), "stats" (entity accuracy statistics), "dashboard" (full feedback loop data), "entity_timeline" (score history for an entity), "layer_timeseries" (per-layer contribution over time), "snapshot_diff" (compare two graph snapshots).',
15859
16029
  inputSchema: {
15860
- mode: z21.enum(["report", "list", "stats", "dashboard", "entity_timeline", "layer_timeseries", "snapshot_diff"]).describe("Operation mode"),
16030
+ mode: z21.enum(["report", "list", "stats", "dashboard", "entity_timeline", "layer_timeseries", "snapshot_diff", "suppress", "unsuppress"]).describe("Operation mode"),
15861
16031
  entity: z21.string().optional().describe("Entity name (required for report and entity_timeline modes, optional filter for list/stats)"),
15862
16032
  note_path: z21.string().optional().describe("Note path where the wikilink appeared (for report mode)"),
15863
16033
  context: z21.string().optional().describe("Surrounding text context (for report mode)"),
@@ -15904,6 +16074,9 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
15904
16074
  ).run(entity, note_path);
15905
16075
  }
15906
16076
  const suppressionUpdated = updateSuppressionList(stateDb2) > 0;
16077
+ if (!correct) {
16078
+ suppressEntity(stateDb2, entity);
16079
+ }
15907
16080
  result = {
15908
16081
  mode: "report",
15909
16082
  reported: {
@@ -15982,6 +16155,38 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
15982
16155
  };
15983
16156
  break;
15984
16157
  }
16158
+ case "suppress": {
16159
+ if (!entity) {
16160
+ return {
16161
+ content: [{ type: "text", text: JSON.stringify({ error: "entity is required for suppress mode" }) }],
16162
+ isError: true
16163
+ };
16164
+ }
16165
+ suppressEntity(stateDb2, entity);
16166
+ result = {
16167
+ mode: "suppress",
16168
+ entity,
16169
+ suppressed: true,
16170
+ total_suppressed: getSuppressedCount(stateDb2)
16171
+ };
16172
+ break;
16173
+ }
16174
+ case "unsuppress": {
16175
+ if (!entity) {
16176
+ return {
16177
+ content: [{ type: "text", text: JSON.stringify({ error: "entity is required for unsuppress mode" }) }],
16178
+ isError: true
16179
+ };
16180
+ }
16181
+ const wasRemoved = unsuppressEntity(stateDb2, entity);
16182
+ result = {
16183
+ mode: "unsuppress",
16184
+ entity,
16185
+ was_suppressed: wasRemoved,
16186
+ total_suppressed: getSuppressedCount(stateDb2)
16187
+ };
16188
+ break;
16189
+ }
15985
16190
  }
15986
16191
  return {
15987
16192
  content: [
@@ -15995,8 +16200,142 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
15995
16200
  );
15996
16201
  }
15997
16202
 
15998
- // src/tools/write/config.ts
16203
+ // src/tools/write/corrections.ts
15999
16204
  import { z as z22 } from "zod";
16205
+
16206
+ // src/core/write/corrections.ts
16207
+ function recordCorrection(stateDb2, type, description, source = "user", entity, notePath) {
16208
+ const result = stateDb2.db.prepare(`
16209
+ INSERT INTO corrections (entity, note_path, correction_type, description, source)
16210
+ VALUES (?, ?, ?, ?, ?)
16211
+ `).run(entity ?? null, notePath ?? null, type, description, source);
16212
+ return stateDb2.db.prepare(
16213
+ "SELECT * FROM corrections WHERE id = ?"
16214
+ ).get(result.lastInsertRowid);
16215
+ }
16216
+ function listCorrections(stateDb2, status, entity, limit = 50) {
16217
+ const conditions = [];
16218
+ const params = [];
16219
+ if (status) {
16220
+ conditions.push("status = ?");
16221
+ params.push(status);
16222
+ }
16223
+ if (entity) {
16224
+ conditions.push("entity = ? COLLATE NOCASE");
16225
+ params.push(entity);
16226
+ }
16227
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
16228
+ params.push(limit);
16229
+ return stateDb2.db.prepare(
16230
+ `SELECT * FROM corrections ${where} ORDER BY created_at DESC LIMIT ?`
16231
+ ).all(...params);
16232
+ }
16233
+ function resolveCorrection(stateDb2, id, newStatus) {
16234
+ const result = stateDb2.db.prepare(`
16235
+ UPDATE corrections
16236
+ SET status = ?, resolved_at = datetime('now')
16237
+ WHERE id = ?
16238
+ `).run(newStatus, id);
16239
+ return result.changes > 0;
16240
+ }
16241
+
16242
+ // src/tools/write/corrections.ts
16243
+ function registerCorrectionTools(server2, getStateDb) {
16244
+ server2.tool(
16245
+ "vault_record_correction",
16246
+ 'Record a persistent correction (e.g., "that link is wrong", "undo that"). Survives across sessions.',
16247
+ {
16248
+ correction_type: z22.enum(["wrong_link", "wrong_entity", "wrong_category", "general"]).describe("Type of correction"),
16249
+ description: z22.string().describe("What went wrong and what should be done"),
16250
+ entity: z22.string().optional().describe("Entity name (if correction is about a specific entity)"),
16251
+ note_path: z22.string().optional().describe("Note path (if correction is about a specific note)")
16252
+ },
16253
+ async ({ correction_type, description, entity, note_path }) => {
16254
+ const stateDb2 = getStateDb();
16255
+ if (!stateDb2) {
16256
+ return {
16257
+ content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available" }) }],
16258
+ isError: true
16259
+ };
16260
+ }
16261
+ const correction = recordCorrection(stateDb2, correction_type, description, "user", entity, note_path);
16262
+ return {
16263
+ content: [{
16264
+ type: "text",
16265
+ text: JSON.stringify({
16266
+ recorded: true,
16267
+ correction
16268
+ }, null, 2)
16269
+ }]
16270
+ };
16271
+ }
16272
+ );
16273
+ server2.tool(
16274
+ "vault_list_corrections",
16275
+ "List recorded corrections, optionally filtered by status or entity.",
16276
+ {
16277
+ status: z22.enum(["pending", "applied", "dismissed"]).optional().describe("Filter by status"),
16278
+ entity: z22.string().optional().describe("Filter by entity name"),
16279
+ limit: z22.number().min(1).max(200).default(50).describe("Max entries to return")
16280
+ },
16281
+ async ({ status, entity, limit }) => {
16282
+ const stateDb2 = getStateDb();
16283
+ if (!stateDb2) {
16284
+ return {
16285
+ content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available" }) }],
16286
+ isError: true
16287
+ };
16288
+ }
16289
+ const corrections = listCorrections(stateDb2, status, entity, limit);
16290
+ return {
16291
+ content: [{
16292
+ type: "text",
16293
+ text: JSON.stringify({
16294
+ corrections,
16295
+ count: corrections.length
16296
+ }, null, 2)
16297
+ }]
16298
+ };
16299
+ }
16300
+ );
16301
+ server2.tool(
16302
+ "vault_resolve_correction",
16303
+ "Resolve a correction by marking it as applied or dismissed.",
16304
+ {
16305
+ correction_id: z22.number().describe("ID of the correction to resolve"),
16306
+ status: z22.enum(["applied", "dismissed"]).describe("New status")
16307
+ },
16308
+ async ({ correction_id, status }) => {
16309
+ const stateDb2 = getStateDb();
16310
+ if (!stateDb2) {
16311
+ return {
16312
+ content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available" }) }],
16313
+ isError: true
16314
+ };
16315
+ }
16316
+ const resolved = resolveCorrection(stateDb2, correction_id, status);
16317
+ if (!resolved) {
16318
+ return {
16319
+ content: [{ type: "text", text: JSON.stringify({ error: `Correction ${correction_id} not found` }) }],
16320
+ isError: true
16321
+ };
16322
+ }
16323
+ return {
16324
+ content: [{
16325
+ type: "text",
16326
+ text: JSON.stringify({
16327
+ resolved: true,
16328
+ correction_id,
16329
+ status
16330
+ }, null, 2)
16331
+ }]
16332
+ };
16333
+ }
16334
+ );
16335
+ }
16336
+
16337
+ // src/tools/write/config.ts
16338
+ import { z as z23 } from "zod";
16000
16339
  import { saveFlywheelConfigToDb as saveFlywheelConfigToDb2 } from "@velvetmonkey/vault-core";
16001
16340
  function registerConfigTools(server2, getConfig, setConfig, getStateDb) {
16002
16341
  server2.registerTool(
@@ -16005,9 +16344,9 @@ function registerConfigTools(server2, getConfig, setConfig, getStateDb) {
16005
16344
  title: "Flywheel Config",
16006
16345
  description: 'Read or update Flywheel configuration.\n- "get": Returns the current FlywheelConfig\n- "set": Updates a single config key and returns the updated config\n\nExample: flywheel_config({ mode: "get" })\nExample: flywheel_config({ mode: "set", key: "exclude_analysis_tags", value: ["habit", "daily"] })',
16007
16346
  inputSchema: {
16008
- mode: z22.enum(["get", "set"]).describe("Operation mode"),
16009
- key: z22.string().optional().describe("Config key to update (required for set mode)"),
16010
- value: z22.unknown().optional().describe("New value for the key (required for set mode)")
16347
+ mode: z23.enum(["get", "set"]).describe("Operation mode"),
16348
+ key: z23.string().optional().describe("Config key to update (required for set mode)"),
16349
+ value: z23.unknown().optional().describe("New value for the key (required for set mode)")
16011
16350
  }
16012
16351
  },
16013
16352
  async ({ mode, key, value }) => {
@@ -16045,7 +16384,7 @@ function registerConfigTools(server2, getConfig, setConfig, getStateDb) {
16045
16384
  }
16046
16385
 
16047
16386
  // src/tools/write/enrich.ts
16048
- import { z as z23 } from "zod";
16387
+ import { z as z24 } from "zod";
16049
16388
  import * as fs29 from "fs/promises";
16050
16389
  import * as path30 from "path";
16051
16390
  function hasSkipWikilinks(content) {
@@ -16100,9 +16439,9 @@ function registerInitTools(server2, vaultPath2, getStateDb) {
16100
16439
  "vault_init",
16101
16440
  "Initialize vault for Flywheel \u2014 scans legacy notes with zero wikilinks and applies entity links. Safe to re-run (idempotent). Use dry_run (default) to preview.",
16102
16441
  {
16103
- dry_run: z23.boolean().default(true).describe("If true (default), preview what would be linked without modifying files"),
16104
- batch_size: z23.number().default(50).describe("Maximum notes to process per invocation (default: 50)"),
16105
- offset: z23.number().default(0).describe("Skip this many eligible notes (for pagination across invocations)")
16442
+ dry_run: z24.boolean().default(true).describe("If true (default), preview what would be linked without modifying files"),
16443
+ batch_size: z24.number().default(50).describe("Maximum notes to process per invocation (default: 50)"),
16444
+ offset: z24.number().default(0).describe("Skip this many eligible notes (for pagination across invocations)")
16106
16445
  },
16107
16446
  async ({ dry_run, batch_size, offset }) => {
16108
16447
  const startTime = Date.now();
@@ -16197,7 +16536,7 @@ function registerInitTools(server2, vaultPath2, getStateDb) {
16197
16536
  }
16198
16537
 
16199
16538
  // src/tools/read/metrics.ts
16200
- import { z as z24 } from "zod";
16539
+ import { z as z25 } from "zod";
16201
16540
 
16202
16541
  // src/core/shared/metrics.ts
16203
16542
  var ALL_METRICS = [
@@ -16363,10 +16702,10 @@ function registerMetricsTools(server2, getIndex, getStateDb) {
16363
16702
  title: "Vault Growth",
16364
16703
  description: 'Track vault growth over time. Modes: "current" (live snapshot), "history" (time series), "trends" (deltas vs N days ago), "index_activity" (rebuild history). Tracks 11 metrics: note_count, link_count, orphan_count, tag_count, entity_count, avg_links_per_note, link_density, connected_ratio, wikilink_accuracy, wikilink_feedback_volume, wikilink_suppressed_count.',
16365
16704
  inputSchema: {
16366
- mode: z24.enum(["current", "history", "trends", "index_activity"]).describe("Query mode: current snapshot, historical time series, trend analysis, or index rebuild activity"),
16367
- metric: z24.string().optional().describe('Filter to specific metric (e.g., "note_count"). Omit for all metrics.'),
16368
- days_back: z24.number().optional().describe("Number of days to look back for history/trends (default: 30)"),
16369
- limit: z24.number().optional().describe("Number of recent events to return for index_activity mode (default: 20)")
16705
+ mode: z25.enum(["current", "history", "trends", "index_activity"]).describe("Query mode: current snapshot, historical time series, trend analysis, or index rebuild activity"),
16706
+ metric: z25.string().optional().describe('Filter to specific metric (e.g., "note_count"). Omit for all metrics.'),
16707
+ days_back: z25.number().optional().describe("Number of days to look back for history/trends (default: 30)"),
16708
+ limit: z25.number().optional().describe("Number of recent events to return for index_activity mode (default: 20)")
16370
16709
  }
16371
16710
  },
16372
16711
  async ({ mode, metric, days_back, limit: eventLimit }) => {
@@ -16439,7 +16778,7 @@ function registerMetricsTools(server2, getIndex, getStateDb) {
16439
16778
  }
16440
16779
 
16441
16780
  // src/tools/read/activity.ts
16442
- import { z as z25 } from "zod";
16781
+ import { z as z26 } from "zod";
16443
16782
 
16444
16783
  // src/core/shared/toolTracking.ts
16445
16784
  function recordToolInvocation(stateDb2, event) {
@@ -16599,10 +16938,10 @@ function registerActivityTools(server2, getStateDb, getSessionId2) {
16599
16938
  title: "Vault Activity",
16600
16939
  description: 'Track tool usage patterns and session activity. Modes:\n- "session": Current session summary (tools called, notes accessed)\n- "sessions": List of recent sessions\n- "note_access": Notes ranked by query frequency\n- "tool_usage": Tool usage patterns (most-used tools, avg duration)',
16601
16940
  inputSchema: {
16602
- mode: z25.enum(["session", "sessions", "note_access", "tool_usage"]).describe("Activity query mode"),
16603
- session_id: z25.string().optional().describe("Specific session ID (for session mode, defaults to current)"),
16604
- days_back: z25.number().optional().describe("Number of days to look back (default: 30)"),
16605
- limit: z25.number().optional().describe("Maximum results to return (default: 20)")
16941
+ mode: z26.enum(["session", "sessions", "note_access", "tool_usage"]).describe("Activity query mode"),
16942
+ session_id: z26.string().optional().describe("Specific session ID (for session mode, defaults to current)"),
16943
+ days_back: z26.number().optional().describe("Number of days to look back (default: 30)"),
16944
+ limit: z26.number().optional().describe("Maximum results to return (default: 20)")
16606
16945
  }
16607
16946
  },
16608
16947
  async ({ mode, session_id, days_back, limit: resultLimit }) => {
@@ -16669,7 +17008,7 @@ function registerActivityTools(server2, getStateDb, getSessionId2) {
16669
17008
  }
16670
17009
 
16671
17010
  // src/tools/read/similarity.ts
16672
- import { z as z26 } from "zod";
17011
+ import { z as z27 } from "zod";
16673
17012
 
16674
17013
  // src/core/read/similarity.ts
16675
17014
  import * as fs30 from "fs";
@@ -16933,9 +17272,9 @@ function registerSimilarityTools(server2, getIndex, getVaultPath, getStateDb) {
16933
17272
  title: "Find Similar Notes",
16934
17273
  description: "Find notes similar to a given note using FTS5 keyword matching. When embeddings have been built (via init_semantic), automatically uses hybrid ranking (BM25 + embedding similarity via Reciprocal Rank Fusion). Use exclude_linked to filter out notes already connected via wikilinks.",
16935
17274
  inputSchema: {
16936
- path: z26.string().describe('Path to the source note (relative to vault root, e.g. "projects/alpha.md")'),
16937
- limit: z26.number().optional().describe("Maximum number of similar notes to return (default: 10)"),
16938
- exclude_linked: z26.boolean().optional().describe("Exclude notes already linked to/from the source note (default: true)")
17275
+ path: z27.string().describe('Path to the source note (relative to vault root, e.g. "projects/alpha.md")'),
17276
+ limit: z27.number().optional().describe("Maximum number of similar notes to return (default: 10)"),
17277
+ exclude_linked: z27.boolean().optional().describe("Exclude notes already linked to/from the source note (default: true)")
16939
17278
  }
16940
17279
  },
16941
17280
  async ({ path: path33, limit, exclude_linked }) => {
@@ -16979,7 +17318,7 @@ function registerSimilarityTools(server2, getIndex, getVaultPath, getStateDb) {
16979
17318
  }
16980
17319
 
16981
17320
  // src/tools/read/semantic.ts
16982
- import { z as z27 } from "zod";
17321
+ import { z as z28 } from "zod";
16983
17322
  import { getAllEntitiesFromDb } from "@velvetmonkey/vault-core";
16984
17323
  function registerSemanticTools(server2, getVaultPath, getStateDb) {
16985
17324
  server2.registerTool(
@@ -16988,7 +17327,7 @@ function registerSemanticTools(server2, getVaultPath, getStateDb) {
16988
17327
  title: "Initialize Semantic Search",
16989
17328
  description: "Download the embedding model and build semantic search index for this vault. After running, search and find_similar automatically use hybrid ranking (BM25 + semantic). Run once per vault \u2014 subsequent calls skip already-embedded notes unless force=true.",
16990
17329
  inputSchema: {
16991
- force: z27.boolean().optional().describe(
17330
+ force: z28.boolean().optional().describe(
16992
17331
  "Rebuild all embeddings even if they already exist (default: false)"
16993
17332
  )
16994
17333
  }
@@ -17068,7 +17407,7 @@ function registerSemanticTools(server2, getVaultPath, getStateDb) {
17068
17407
 
17069
17408
  // src/tools/read/merges.ts
17070
17409
  init_levenshtein();
17071
- import { z as z28 } from "zod";
17410
+ import { z as z29 } from "zod";
17072
17411
  import { getAllEntitiesFromDb as getAllEntitiesFromDb2, getDismissedMergePairs, recordMergeDismissal } from "@velvetmonkey/vault-core";
17073
17412
  function normalizeName(name) {
17074
17413
  return name.toLowerCase().replace(/[.\-_]/g, "").replace(/js$/, "").replace(/ts$/, "");
@@ -17078,7 +17417,7 @@ function registerMergeTools2(server2, getStateDb) {
17078
17417
  "suggest_entity_merges",
17079
17418
  "Find potential duplicate entities that could be merged based on name similarity",
17080
17419
  {
17081
- limit: z28.number().optional().default(50).describe("Maximum number of suggestions to return")
17420
+ limit: z29.number().optional().default(50).describe("Maximum number of suggestions to return")
17082
17421
  },
17083
17422
  async ({ limit }) => {
17084
17423
  const stateDb2 = getStateDb();
@@ -17180,11 +17519,11 @@ function registerMergeTools2(server2, getStateDb) {
17180
17519
  "dismiss_merge_suggestion",
17181
17520
  "Permanently dismiss a merge suggestion so it never reappears",
17182
17521
  {
17183
- source_path: z28.string().describe("Path of the source entity"),
17184
- target_path: z28.string().describe("Path of the target entity"),
17185
- source_name: z28.string().describe("Name of the source entity"),
17186
- target_name: z28.string().describe("Name of the target entity"),
17187
- reason: z28.string().describe("Original suggestion reason")
17522
+ source_path: z29.string().describe("Path of the source entity"),
17523
+ target_path: z29.string().describe("Path of the target entity"),
17524
+ source_name: z29.string().describe("Name of the source entity"),
17525
+ target_name: z29.string().describe("Name of the target entity"),
17526
+ reason: z29.string().describe("Original suggestion reason")
17188
17527
  },
17189
17528
  async ({ source_path, target_path, source_name, target_name, reason }) => {
17190
17529
  const stateDb2 = getStateDb();
@@ -17583,6 +17922,7 @@ registerSystemTools2(server, vaultPath);
17583
17922
  registerPolicyTools(server, vaultPath);
17584
17923
  registerTagTools(server, () => vaultIndex, () => vaultPath);
17585
17924
  registerWikilinkFeedbackTools(server, () => stateDb);
17925
+ registerCorrectionTools(server, () => stateDb);
17586
17926
  registerInitTools(server, vaultPath, () => stateDb);
17587
17927
  registerConfigTools(
17588
17928
  server,
@@ -18259,7 +18599,7 @@ async function runPostIndexWork(index) {
18259
18599
  (e) => e.nameLower === link || (e.aliases ?? []).some((a) => a.toLowerCase() === link)
18260
18600
  );
18261
18601
  if (entity) {
18262
- recordFeedback(stateDb, entity.name, "implicit:kept", entry.file, true);
18602
+ recordFeedback(stateDb, entity.name, "implicit:kept", entry.file, true, 0.8);
18263
18603
  markPositive.run(entry.file, link);
18264
18604
  }
18265
18605
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.0.47",
3
+ "version": "2.0.49",
4
4
  "description": "MCP server that gives Claude full read/write access to your Obsidian vault. Select from 42 tools for search, backlinks, graph queries, mutations, and hybrid semantic search.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@modelcontextprotocol/sdk": "^1.25.1",
55
- "@velvetmonkey/vault-core": "^2.0.47",
55
+ "@velvetmonkey/vault-core": "^2.0.49",
56
56
  "better-sqlite3": "^11.0.0",
57
57
  "chokidar": "^4.0.0",
58
58
  "gray-matter": "^4.0.3",