@velvetmonkey/flywheel-memory 2.0.48 → 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 +308 -151
  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);
@@ -3607,26 +3706,20 @@ function unsuppressEntity(stateDb2, entity) {
3607
3706
  ).run(entity);
3608
3707
  return result.changes > 0;
3609
3708
  }
3610
- function isSuppressed(stateDb2, entity, folder) {
3709
+ function isSuppressed(stateDb2, entity, folder, now) {
3611
3710
  const row = stateDb2.db.prepare(
3612
- "SELECT entity FROM wikilink_suppressions WHERE entity = ? COLLATE NOCASE"
3613
- ).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);
3614
3715
  if (row) return true;
3615
3716
  if (folder !== void 0) {
3616
- const folderStats = stateDb2.db.prepare(`
3617
- SELECT
3618
- COUNT(*) as total,
3619
- SUM(CASE WHEN correct = 0 THEN 1 ELSE 0 END) as false_positives
3620
- FROM wikilink_feedback
3621
- WHERE entity = ? COLLATE NOCASE AND (
3622
- CASE WHEN ? = '' THEN note_path NOT LIKE '%/%'
3623
- ELSE note_path LIKE ? || '/%'
3624
- END
3625
- )
3626
- `).get(entity, folder, folder);
3627
- if (folderStats && folderStats.total >= FOLDER_SUPPRESSION_MIN_COUNT) {
3628
- const fpRate = folderStats.false_positives / folderStats.total;
3629
- 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) {
3630
3723
  return true;
3631
3724
  }
3632
3725
  }
@@ -3656,59 +3749,65 @@ function computeBoostFromAccuracy(accuracy, sampleCount) {
3656
3749
  }
3657
3750
  return 0;
3658
3751
  }
3659
- function getAllFeedbackBoosts(stateDb2, folder) {
3660
- const globalRows = stateDb2.db.prepare(`
3661
- SELECT
3662
- entity,
3663
- COUNT(*) as total,
3664
- SUM(CASE WHEN correct = 1 THEN 1 ELSE 0 END) as correct_count
3665
- FROM wikilink_feedback
3666
- GROUP BY entity
3667
- HAVING total >= ?
3668
- `).all(FEEDBACK_BOOST_MIN_SAMPLES);
3669
- let folderStats = null;
3752
+ function getAllFeedbackBoosts(stateDb2, folder, now) {
3753
+ const globalStats = getWeightedEntityStats(stateDb2, now);
3754
+ let folderStatsMap = null;
3670
3755
  if (folder !== void 0) {
3671
- const folderRows = stateDb2.db.prepare(`
3672
- SELECT
3673
- entity,
3674
- COUNT(*) as total,
3675
- SUM(CASE WHEN correct = 1 THEN 1 ELSE 0 END) as correct_count
3676
- FROM wikilink_feedback
3677
- WHERE (
3678
- CASE WHEN ? = '' THEN note_path NOT LIKE '%/%'
3679
- ELSE note_path LIKE ? || '/%'
3680
- END
3681
- )
3682
- GROUP BY entity
3683
- HAVING total >= ?
3684
- `).all(folder, folder, FEEDBACK_BOOST_MIN_SAMPLES);
3685
- folderStats = /* @__PURE__ */ new Map();
3686
- for (const row of folderRows) {
3687
- folderStats.set(row.entity, {
3688
- accuracy: row.correct_count / row.total,
3689
- count: row.total
3690
- });
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
+ }
3691
3765
  }
3692
3766
  }
3693
3767
  const boosts = /* @__PURE__ */ new Map();
3694
- for (const row of globalRows) {
3768
+ for (const stat4 of globalStats) {
3769
+ if (stat4.rawTotal < FEEDBACK_BOOST_MIN_SAMPLES) continue;
3695
3770
  let accuracy;
3696
3771
  let sampleCount;
3697
- const fs32 = folderStats?.get(row.entity);
3698
- if (fs32 && fs32.count >= FEEDBACK_BOOST_MIN_SAMPLES) {
3699
- accuracy = fs32.accuracy;
3700
- 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;
3701
3776
  } else {
3702
- accuracy = row.correct_count / row.total;
3703
- sampleCount = row.total;
3777
+ accuracy = stat4.weightedAccuracy;
3778
+ sampleCount = stat4.rawTotal;
3704
3779
  }
3705
3780
  const boost = computeBoostFromAccuracy(accuracy, sampleCount);
3706
3781
  if (boost !== 0) {
3707
- boosts.set(row.entity, boost);
3782
+ boosts.set(stat4.entity, boost);
3708
3783
  }
3709
3784
  }
3710
3785
  return boosts;
3711
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
+ }
3712
3811
  function trackWikilinkApplications(stateDb2, notePath, entities) {
3713
3812
  const upsert = stateDb2.db.prepare(`
3714
3813
  INSERT INTO wikilink_applications (entity, note_path, applied_at, status)
@@ -3730,6 +3829,18 @@ function getTrackedApplications(stateDb2, notePath) {
3730
3829
  ).all(notePath);
3731
3830
  return rows.map((r) => r.entity);
3732
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
+ }
3733
3844
  function getStoredNoteLinks(stateDb2, notePath) {
3734
3845
  const rows = stateDb2.db.prepare(
3735
3846
  "SELECT target FROM note_links WHERE note_path = ?"
@@ -3783,17 +3894,18 @@ function updateStoredNoteTags(stateDb2, notePath, currentTags) {
3783
3894
  tx();
3784
3895
  }
3785
3896
  function processImplicitFeedback(stateDb2, notePath, currentContent) {
3786
- const tracked = getTrackedApplications(stateDb2, notePath);
3787
- if (tracked.length === 0) return [];
3897
+ const trackedWithTime = getTrackedApplicationsWithTime(stateDb2, notePath);
3898
+ if (trackedWithTime.length === 0) return [];
3788
3899
  const currentLinks = extractLinkedEntities(currentContent);
3789
3900
  const removed = [];
3790
3901
  const markRemoved = stateDb2.db.prepare(
3791
3902
  `UPDATE wikilink_applications SET status = 'removed' WHERE entity = ? AND note_path = ?`
3792
3903
  );
3793
3904
  const transaction = stateDb2.db.transaction(() => {
3794
- for (const entity of tracked) {
3905
+ for (const { entity, applied_at } of trackedWithTime) {
3795
3906
  if (!currentLinks.has(entity.toLowerCase())) {
3796
- recordFeedback(stateDb2, entity, "implicit:removed", notePath, false);
3907
+ const confidence = computeImplicitRemovalConfidence(applied_at);
3908
+ recordFeedback(stateDb2, entity, "implicit:removed", notePath, false, confidence);
3797
3909
  markRemoved.run(entity, notePath);
3798
3910
  removed.push(entity);
3799
3911
  }
@@ -3806,11 +3918,11 @@ function processImplicitFeedback(stateDb2, notePath, currentContent) {
3806
3918
  return removed;
3807
3919
  }
3808
3920
  var TIER_LABELS = [
3809
- { label: "Champion (+5)", boost: 5, minAccuracy: 0.95, minSamples: 20 },
3810
- { label: "Strong (+2)", boost: 2, minAccuracy: 0.8, minSamples: 5 },
3811
- { label: "Neutral (0)", boost: 0, minAccuracy: 0.6, minSamples: 5 },
3812
- { label: "Weak (-2)", boost: -2, minAccuracy: 0.4, minSamples: 5 },
3813
- { 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 }
3814
3926
  ];
3815
3927
  function getDashboardData(stateDb2) {
3816
3928
  const entityStats = getEntityStats(stateDb2);
@@ -5271,14 +5383,14 @@ function stem(word) {
5271
5383
  }
5272
5384
  function tokenize(text) {
5273
5385
  const cleanText = text.replace(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g, "$1").replace(/[*_`#\[\]()]/g, " ").toLowerCase();
5274
- const words = cleanText.match(/\b[a-z]{3,}\b/g) || [];
5386
+ const words = cleanText.match(/\b[a-z][a-z0-9]{2,}\b/g) || [];
5275
5387
  return words.filter((word) => !STOPWORDS.has(word));
5276
5388
  }
5277
5389
 
5278
5390
  // src/core/shared/cooccurrence.ts
5279
5391
  import { readdir as readdir2, readFile as readFile2 } from "fs/promises";
5280
5392
  import path10 from "path";
5281
- var DEFAULT_MIN_COOCCURRENCE = 2;
5393
+ var DEFAULT_MIN_COOCCURRENCE = 0.5;
5282
5394
  var EXCLUDED_FOLDERS2 = /* @__PURE__ */ new Set([
5283
5395
  "templates",
5284
5396
  ".obsidian",
@@ -5300,12 +5412,12 @@ function noteContainsEntity(content, entityName) {
5300
5412
  }
5301
5413
  return matchCount / entityTokens.length >= 0.5;
5302
5414
  }
5303
- function incrementCooccurrence(associations, entityA, entityB) {
5415
+ function incrementCooccurrence(associations, entityA, entityB, weight = 1) {
5304
5416
  if (!associations[entityA]) {
5305
5417
  associations[entityA] = /* @__PURE__ */ new Map();
5306
5418
  }
5307
5419
  const current = associations[entityA].get(entityB) || 0;
5308
- associations[entityA].set(entityB, current + 1);
5420
+ associations[entityA].set(entityB, current + weight);
5309
5421
  }
5310
5422
  async function* walkMarkdownFiles2(dir, baseDir) {
5311
5423
  try {
@@ -5329,6 +5441,7 @@ async function* walkMarkdownFiles2(dir, baseDir) {
5329
5441
  async function mineCooccurrences(vaultPath2, entities, options = {}) {
5330
5442
  const { minCount = DEFAULT_MIN_COOCCURRENCE } = options;
5331
5443
  const associations = {};
5444
+ const documentFrequency = /* @__PURE__ */ new Map();
5332
5445
  let notesScanned = 0;
5333
5446
  const validEntities = entities.filter((e) => e.length <= 30);
5334
5447
  for await (const file of walkMarkdownFiles2(vaultPath2, vaultPath2)) {
@@ -5341,10 +5454,15 @@ async function mineCooccurrences(vaultPath2, entities, options = {}) {
5341
5454
  mentionedEntities.push(entity);
5342
5455
  }
5343
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;
5344
5462
  for (const entityA of mentionedEntities) {
5345
5463
  for (const entityB of mentionedEntities) {
5346
5464
  if (entityA !== entityB) {
5347
- incrementCooccurrence(associations, entityA, entityB);
5465
+ incrementCooccurrence(associations, entityA, entityB, adamicAdarWeight);
5348
5466
  }
5349
5467
  }
5350
5468
  }
@@ -5362,6 +5480,8 @@ async function mineCooccurrences(vaultPath2, entities, options = {}) {
5362
5480
  return {
5363
5481
  associations,
5364
5482
  minCount,
5483
+ documentFrequency,
5484
+ totalNotesScanned: notesScanned,
5365
5485
  _metadata: {
5366
5486
  generated_at: (/* @__PURE__ */ new Date()).toISOString(),
5367
5487
  total_associations: totalAssociations,
@@ -5369,26 +5489,50 @@ async function mineCooccurrences(vaultPath2, entities, options = {}) {
5369
5489
  }
5370
5490
  };
5371
5491
  }
5372
- 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
+ }
5373
5505
  function getCooccurrenceBoost(entityName, matchedEntities, cooccurrenceIndex2, recencyIndex2) {
5374
5506
  if (!cooccurrenceIndex2) return 0;
5375
- let boost = 0;
5376
- 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;
5377
5511
  for (const matched of matchedEntities) {
5378
5512
  const entityAssocs = associations[matched];
5379
- if (entityAssocs) {
5380
- const count = entityAssocs.get(entityName) || 0;
5381
- if (count >= minCount) {
5382
- boost += 3;
5383
- }
5384
- }
5385
- }
5386
- 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) {
5387
5523
  const recencyBoostVal = getRecencyBoost(entityName, recencyIndex2);
5388
5524
  const recencyMultiplier = recencyBoostVal > 0 ? 1.5 : 0.5;
5389
- boost = Math.round(boost * recencyMultiplier);
5525
+ boost = boost * recencyMultiplier;
5390
5526
  }
5391
- 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));
5392
5536
  }
5393
5537
 
5394
5538
  // src/core/write/edgeWeights.ts
@@ -5964,8 +6108,8 @@ function isLikelyArticleTitle(name) {
5964
6108
  var STRICTNESS_CONFIGS = {
5965
6109
  conservative: {
5966
6110
  minWordLength: 3,
5967
- minSuggestionScore: 15,
5968
- // Requires exact match (10) + at least one stem (5)
6111
+ minSuggestionScore: 18,
6112
+ // Requires exact match (10) + stem (5) + at least one boost
5969
6113
  minMatchRatio: 0.6,
5970
6114
  // 60% of multi-word entity must match
5971
6115
  requireMultipleMatches: true,
@@ -5977,8 +6121,8 @@ var STRICTNESS_CONFIGS = {
5977
6121
  },
5978
6122
  balanced: {
5979
6123
  minWordLength: 3,
5980
- minSuggestionScore: 8,
5981
- // At least one exact match or two stem matches
6124
+ minSuggestionScore: 10,
6125
+ // Exact match (10) or two stem matches
5982
6126
  minMatchRatio: 0.4,
5983
6127
  // 40% of multi-word entity must match
5984
6128
  requireMultipleMatches: false,
@@ -6125,7 +6269,7 @@ function getAdaptiveMinScore(contentLength, baseScore) {
6125
6269
  var MIN_SUGGESTION_SCORE = STRICTNESS_CONFIGS.balanced.minSuggestionScore;
6126
6270
  var MIN_MATCH_RATIO = STRICTNESS_CONFIGS.balanced.minMatchRatio;
6127
6271
  var FULL_ALIAS_MATCH_BONUS = 8;
6128
- function scoreNameAgainstContent(name, contentTokens, contentStems, config) {
6272
+ function scoreNameAgainstContent(name, contentTokens, contentStems, config, coocIndex) {
6129
6273
  const nameTokens = tokenize(name);
6130
6274
  if (nameTokens.length === 0) {
6131
6275
  return { score: 0, matchedWords: 0, exactMatches: 0, totalTokens: 0 };
@@ -6137,24 +6281,26 @@ function scoreNameAgainstContent(name, contentTokens, contentStems, config) {
6137
6281
  for (let i = 0; i < nameTokens.length; i++) {
6138
6282
  const token = nameTokens[i];
6139
6283
  const nameStem = nameStems[i];
6284
+ const idfWeight = coocIndex ? tokenIdf(token, coocIndex) : 1;
6140
6285
  if (contentTokens.has(token)) {
6141
- score += config.exactMatchBonus;
6286
+ score += config.exactMatchBonus * idfWeight;
6142
6287
  matchedWords++;
6143
6288
  exactMatches++;
6144
6289
  } else if (contentStems.has(nameStem)) {
6145
- score += config.stemMatchBonus;
6290
+ score += config.stemMatchBonus * idfWeight;
6146
6291
  matchedWords++;
6147
6292
  }
6148
6293
  }
6294
+ score = Math.round(score * 10) / 10;
6149
6295
  return { score, matchedWords, exactMatches, totalTokens: nameTokens.length };
6150
6296
  }
6151
- function scoreEntity(entity, contentTokens, contentStems, config) {
6297
+ function scoreEntity(entity, contentTokens, contentStems, config, coocIndex) {
6152
6298
  const entityName = getEntityName2(entity);
6153
6299
  const aliases = getEntityAliases(entity);
6154
- const nameResult = scoreNameAgainstContent(entityName, contentTokens, contentStems, config);
6300
+ const nameResult = scoreNameAgainstContent(entityName, contentTokens, contentStems, config, coocIndex);
6155
6301
  let bestAliasResult = { score: 0, matchedWords: 0, exactMatches: 0, totalTokens: 0 };
6156
6302
  for (const alias of aliases) {
6157
- const aliasResult = scoreNameAgainstContent(alias, contentTokens, contentStems, config);
6303
+ const aliasResult = scoreNameAgainstContent(alias, contentTokens, contentStems, config, coocIndex);
6158
6304
  if (aliasResult.score > bestAliasResult.score) {
6159
6305
  bestAliasResult = aliasResult;
6160
6306
  }
@@ -6164,7 +6310,7 @@ function scoreEntity(entity, contentTokens, contentStems, config) {
6164
6310
  if (totalTokens === 0) return 0;
6165
6311
  for (const alias of aliases) {
6166
6312
  const aliasLower = alias.toLowerCase();
6167
- if (aliasLower.length >= 4 && !/\s/.test(aliasLower) && contentTokens.has(aliasLower)) {
6313
+ if (aliasLower.length >= 3 && !/\s/.test(aliasLower) && contentTokens.has(aliasLower)) {
6168
6314
  score += FULL_ALIAS_MATCH_BONUS;
6169
6315
  break;
6170
6316
  }
@@ -6189,7 +6335,7 @@ function getEdgeWeightBoostScore(entityName, map) {
6189
6335
  }
6190
6336
  async function suggestRelatedLinks(content, options = {}) {
6191
6337
  const {
6192
- maxSuggestions = 3,
6338
+ maxSuggestions = 8,
6193
6339
  excludeLinked = true,
6194
6340
  strictness = getEffectiveStrictness(options.notePath),
6195
6341
  notePath,
@@ -6231,6 +6377,7 @@ async function suggestRelatedLinks(content, options = {}) {
6231
6377
  const linkedEntities = excludeLinked ? extractLinkedEntities(content) : /* @__PURE__ */ new Set();
6232
6378
  const noteFolder = notePath ? notePath.split("/")[0] : void 0;
6233
6379
  const feedbackBoosts = moduleStateDb5 ? getAllFeedbackBoosts(moduleStateDb5, noteFolder) : /* @__PURE__ */ new Map();
6380
+ const suppressionPenalties = moduleStateDb5 ? getAllSuppressionPenalties(moduleStateDb5) : /* @__PURE__ */ new Map();
6234
6381
  const edgeWeightMap = moduleStateDb5 ? getEntityEdgeWeightMap(moduleStateDb5) : /* @__PURE__ */ new Map();
6235
6382
  const scoredEntities = [];
6236
6383
  const directlyMatchedEntities = /* @__PURE__ */ new Set();
@@ -6247,13 +6394,7 @@ async function suggestRelatedLinks(content, options = {}) {
6247
6394
  if (linkedEntities.has(entityName.toLowerCase())) {
6248
6395
  continue;
6249
6396
  }
6250
- if (moduleStateDb5 && !disabled.has("feedback")) {
6251
- const noteFolder2 = notePath ? notePath.split("/").slice(0, -1).join("/") : void 0;
6252
- if (isSuppressed(moduleStateDb5, entityName, noteFolder2)) {
6253
- continue;
6254
- }
6255
- }
6256
- 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);
6257
6398
  let score = contentScore;
6258
6399
  if (contentScore > 0) {
6259
6400
  entitiesWithContentMatch.add(entityName);
@@ -6272,10 +6413,12 @@ async function suggestRelatedLinks(content, options = {}) {
6272
6413
  score += layerFeedbackAdj;
6273
6414
  const layerEdgeWeightBoost = disabled.has("edge_weight") ? 0 : getEdgeWeightBoostScore(entityName, edgeWeightMap);
6274
6415
  score += layerEdgeWeightBoost;
6275
- if (score > 0) {
6416
+ if (contentScore > 0) {
6276
6417
  directlyMatchedEntities.add(entityName);
6277
6418
  }
6278
- if (score >= adaptiveMinScore) {
6419
+ const layerSuppressionPenalty = disabled.has("feedback") ? 0 : suppressionPenalties.get(entityName) ?? 0;
6420
+ score += layerSuppressionPenalty;
6421
+ if (contentScore > 0 && score >= adaptiveMinScore) {
6279
6422
  scoredEntities.push({
6280
6423
  name: entityName,
6281
6424
  path: entity.path || "",
@@ -6290,23 +6433,31 @@ async function suggestRelatedLinks(content, options = {}) {
6290
6433
  crossFolderBoost: layerCrossFolderBoost,
6291
6434
  hubBoost: layerHubBoost,
6292
6435
  feedbackAdjustment: layerFeedbackAdj,
6436
+ suppressionPenalty: layerSuppressionPenalty,
6293
6437
  edgeWeightBoost: layerEdgeWeightBoost
6294
6438
  }
6295
6439
  });
6296
6440
  }
6297
6441
  }
6298
- 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) {
6299
6454
  for (const { entity, category } of entitiesWithTypes) {
6300
6455
  const entityName = entity.name;
6301
6456
  if (!entityName) continue;
6302
6457
  if (!disabled.has("length_filter") && entityName.length > MAX_ENTITY_LENGTH) continue;
6303
6458
  if (!disabled.has("article_filter") && isLikelyArticleTitle(entityName)) continue;
6304
6459
  if (linkedEntities.has(entityName.toLowerCase())) continue;
6305
- if (moduleStateDb5 && !disabled.has("feedback")) {
6306
- const noteFolder2 = notePath ? notePath.split("/").slice(0, -1).join("/") : void 0;
6307
- if (isSuppressed(moduleStateDb5, entityName, noteFolder2)) continue;
6308
- }
6309
- const boost = getCooccurrenceBoost(entityName, directlyMatchedEntities, cooccurrenceIndex, recencyIndex);
6460
+ const boost = getCooccurrenceBoost(entityName, cooccurrenceSeeds, cooccurrenceIndex, recencyIndex);
6310
6461
  if (boost > 0) {
6311
6462
  const existing = scoredEntities.find((e) => e.name === entityName);
6312
6463
  if (existing) {
@@ -6317,19 +6468,24 @@ async function suggestRelatedLinks(content, options = {}) {
6317
6468
  const hasContentOverlap = entityTokens.some(
6318
6469
  (token) => contentTokens.has(token) || contentStems.has(stem(token))
6319
6470
  );
6320
- if (!hasContentOverlap) {
6471
+ const strongCooccurrence = boost >= 4;
6472
+ if (!hasContentOverlap && !strongCooccurrence) {
6321
6473
  continue;
6322
6474
  }
6323
- entitiesWithContentMatch.add(entityName);
6475
+ if (hasContentOverlap || strongCooccurrence) {
6476
+ entitiesWithContentMatch.add(entityName);
6477
+ }
6324
6478
  const typeBoost = disabled.has("type_boost") ? 0 : TYPE_BOOST[category] || 0;
6325
6479
  const contextBoost = disabled.has("context_boost") ? 0 : contextBoosts[category] || 0;
6326
- const recencyBoostVal = disabled.has("recency") ? 0 : recencyIndex ? getRecencyBoost(entityName, recencyIndex) : 0;
6480
+ const recencyBoostVal = hasContentOverlap && !disabled.has("recency") ? recencyIndex ? getRecencyBoost(entityName, recencyIndex) : 0 : 0;
6327
6481
  const crossFolderBoost = disabled.has("cross_folder") ? 0 : notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
6328
6482
  const hubBoost = disabled.has("hub_boost") ? 0 : getHubBoost(entity);
6329
6483
  const feedbackAdj = disabled.has("feedback") ? 0 : feedbackBoosts.get(entityName) ?? 0;
6330
6484
  const edgeWeightBoost = disabled.has("edge_weight") ? 0 : getEdgeWeightBoostScore(entityName, edgeWeightMap);
6331
- const totalBoost = boost + typeBoost + contextBoost + recencyBoostVal + crossFolderBoost + hubBoost + feedbackAdj + edgeWeightBoost;
6332
- 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) {
6333
6489
  scoredEntities.push({
6334
6490
  name: entityName,
6335
6491
  path: entity.path || "",
@@ -6344,6 +6500,7 @@ async function suggestRelatedLinks(content, options = {}) {
6344
6500
  crossFolderBoost,
6345
6501
  hubBoost,
6346
6502
  feedbackAdjustment: feedbackAdj,
6503
+ suppressionPenalty: suppPenalty,
6347
6504
  edgeWeightBoost
6348
6505
  }
6349
6506
  });
@@ -6370,10 +6527,6 @@ async function suggestRelatedLinks(content, options = {}) {
6370
6527
  existing.score += boost;
6371
6528
  existing.breakdown.semanticBoost = boost;
6372
6529
  } else if (!linkedEntities.has(match.entityName.toLowerCase())) {
6373
- if (moduleStateDb5 && !disabled.has("feedback")) {
6374
- const noteFolder2 = notePath ? notePath.split("/").slice(0, -1).join("/") : void 0;
6375
- if (isSuppressed(moduleStateDb5, match.entityName, noteFolder2)) continue;
6376
- }
6377
6530
  const entityWithType = entitiesWithTypes.find(
6378
6531
  (et) => et.entity.name === match.entityName
6379
6532
  );
@@ -6387,7 +6540,8 @@ async function suggestRelatedLinks(content, options = {}) {
6387
6540
  const layerCrossFolderBoost = disabled.has("cross_folder") ? 0 : notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
6388
6541
  const layerFeedbackAdj = disabled.has("feedback") ? 0 : feedbackBoosts.get(match.entityName) ?? 0;
6389
6542
  const layerEdgeWeightBoost = disabled.has("edge_weight") ? 0 : getEdgeWeightBoostScore(match.entityName, edgeWeightMap);
6390
- 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;
6391
6545
  if (totalScore >= adaptiveMinScore) {
6392
6546
  scoredEntities.push({
6393
6547
  name: match.entityName,
@@ -6403,6 +6557,7 @@ async function suggestRelatedLinks(content, options = {}) {
6403
6557
  crossFolderBoost: layerCrossFolderBoost,
6404
6558
  hubBoost: layerHubBoost,
6405
6559
  feedbackAdjustment: layerFeedbackAdj,
6560
+ suppressionPenalty: layerSuppPenalty,
6406
6561
  semanticBoost: boost,
6407
6562
  edgeWeightBoost: layerEdgeWeightBoost
6408
6563
  }
@@ -7859,11 +8014,12 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
7859
8014
  text: z2.string().describe("The text to analyze for potential wikilinks"),
7860
8015
  limit: z2.coerce.number().default(50).describe("Maximum number of suggestions to return"),
7861
8016
  offset: z2.coerce.number().default(0).describe("Number of suggestions to skip (for pagination)"),
7862
- 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)")
7863
8019
  },
7864
8020
  outputSchema: SuggestWikilinksOutputSchema
7865
8021
  },
7866
- async ({ text, limit: requestedLimit, offset, detail }) => {
8022
+ async ({ text, limit: requestedLimit, offset, detail, note_path }) => {
7867
8023
  const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
7868
8024
  const index = getIndex();
7869
8025
  const allMatches = findEntityMatches(text, index.entities);
@@ -7878,7 +8034,8 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
7878
8034
  const scored = await suggestRelatedLinks(text, {
7879
8035
  detail: true,
7880
8036
  maxSuggestions: limit,
7881
- strictness: "balanced"
8037
+ strictness: "balanced",
8038
+ ...note_path ? { notePath: note_path } : {}
7882
8039
  });
7883
8040
  if (scored.detailed) {
7884
8041
  output.scored_suggestions = scored.detailed;
@@ -12901,7 +13058,7 @@ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", con
12901
13058
  preserveListNesting: z11.boolean().default(true).describe("Detect and preserve the indentation level of surrounding list items. Set false to disable."),
12902
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."),
12903
13060
  suggestOutgoingLinks: z11.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]], [[Philosophy]]"). Set false to disable.'),
12904
- 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)"),
12905
13062
  validate: z11.boolean().default(true).describe("Check input for common issues (double timestamps, non-markdown bullets, etc.)"),
12906
13063
  normalize: z11.boolean().default(true).describe("Auto-fix common issues before formatting (replace \u2022 with -, trim excessive whitespace, etc.)"),
12907
13064
  guardrails: z11.enum(["warn", "strict", "off"]).default("warn").describe('Output validation mode: "warn" returns issues but proceeds, "strict" blocks on errors, "off" disables'),
@@ -13047,7 +13204,7 @@ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", con
13047
13204
  commit: z11.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
13048
13205
  skipWikilinks: z11.boolean().default(false).describe("If true, skip auto-wikilink application on replacement text"),
13049
13206
  suggestOutgoingLinks: z11.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]], [[Philosophy]]"). Set false to disable.'),
13050
- 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)"),
13051
13208
  validate: z11.boolean().default(true).describe("Check input for common issues (double timestamps, non-markdown bullets, etc.)"),
13052
13209
  normalize: z11.boolean().default(true).describe("Auto-fix common issues before formatting (replace \u2022 with -, trim excessive whitespace, etc.)"),
13053
13210
  guardrails: z11.enum(["warn", "strict", "off"]).default("warn").describe('Output validation mode: "warn" returns issues but proceeds, "strict" blocks on errors, "off" disables'),
@@ -13245,7 +13402,7 @@ function registerTaskTools(server2, vaultPath2) {
13245
13402
  commit: z12.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
13246
13403
  skipWikilinks: z12.boolean().default(false).describe("If true, skip auto-wikilink application (wikilinks are applied by default)"),
13247
13404
  suggestOutgoingLinks: z12.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]], [[Philosophy]]"). Set false to disable.'),
13248
- 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)"),
13249
13406
  preserveListNesting: z12.boolean().default(true).describe("Preserve indentation when inserting into nested lists. Default: true"),
13250
13407
  validate: z12.boolean().default(true).describe("Check input for common issues"),
13251
13408
  normalize: z12.boolean().default(true).describe("Auto-fix common issues before formatting"),
@@ -13386,7 +13543,7 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
13386
13543
  commit: z14.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
13387
13544
  skipWikilinks: z14.boolean().default(false).describe("If true, skip auto-wikilink application (wikilinks are applied by default)"),
13388
13545
  suggestOutgoingLinks: z14.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]], [[Philosophy]]").'),
13389
- 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)"),
13390
13547
  agent_id: z14.string().optional().describe("Agent identifier for multi-agent scoping"),
13391
13548
  session_id: z14.string().optional().describe("Session identifier for conversation scoping")
13392
13549
  },
@@ -18442,7 +18599,7 @@ async function runPostIndexWork(index) {
18442
18599
  (e) => e.nameLower === link || (e.aliases ?? []).some((a) => a.toLowerCase() === link)
18443
18600
  );
18444
18601
  if (entity) {
18445
- recordFeedback(stateDb, entity.name, "implicit:kept", entry.file, true);
18602
+ recordFeedback(stateDb, entity.name, "implicit:kept", entry.file, true, 0.8);
18446
18603
  markPositive.run(entry.file, link);
18447
18604
  }
18448
18605
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.0.48",
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.48",
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",