@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.
- package/dist/index.js +524 -184
- 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
|
|
3497
|
-
var
|
|
3498
|
-
var
|
|
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.
|
|
3502
|
-
|
|
3503
|
-
{ minAccuracy: 0.
|
|
3504
|
-
|
|
3505
|
-
{ minAccuracy: 0, minSamples:
|
|
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
|
|
3563
|
-
const
|
|
3564
|
-
|
|
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
|
-
|
|
3567
|
-
|
|
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
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
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
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
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
|
|
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
|
-
|
|
3600
|
-
|
|
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
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
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
|
|
3648
|
-
|
|
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
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
|
|
3663
|
-
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
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
|
|
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 =
|
|
3685
|
-
if (fs32 && fs32.
|
|
3686
|
-
accuracy = fs32.
|
|
3687
|
-
sampleCount = fs32.
|
|
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 =
|
|
3690
|
-
sampleCount =
|
|
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(
|
|
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
|
|
3774
|
-
if (
|
|
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
|
|
3905
|
+
for (const { entity, applied_at } of trackedWithTime) {
|
|
3782
3906
|
if (!currentLinks.has(entity.toLowerCase())) {
|
|
3783
|
-
|
|
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 (+
|
|
3797
|
-
{ label: "Strong (+
|
|
3798
|
-
{ label: "Neutral (0)", boost: 0, minAccuracy: 0.
|
|
3799
|
-
{ label: "
|
|
3800
|
-
{ label: "
|
|
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]{
|
|
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 =
|
|
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 +
|
|
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 =
|
|
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
|
-
|
|
5363
|
-
const
|
|
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
|
-
|
|
5368
|
-
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
|
|
5372
|
-
}
|
|
5373
|
-
if (
|
|
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 =
|
|
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:
|
|
5955
|
-
// Requires exact match (10) + at least one
|
|
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:
|
|
5968
|
-
//
|
|
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 >=
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
6416
|
+
if (contentScore > 0) {
|
|
6263
6417
|
directlyMatchedEntities.add(entityName);
|
|
6264
6418
|
}
|
|
6265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6471
|
+
const strongCooccurrence = boost >= 4;
|
|
6472
|
+
if (!hasContentOverlap && !strongCooccurrence) {
|
|
6308
6473
|
continue;
|
|
6309
6474
|
}
|
|
6310
|
-
|
|
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") ?
|
|
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
|
|
6319
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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/
|
|
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:
|
|
16009
|
-
key:
|
|
16010
|
-
value:
|
|
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
|
|
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:
|
|
16104
|
-
batch_size:
|
|
16105
|
-
offset:
|
|
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
|
|
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:
|
|
16367
|
-
metric:
|
|
16368
|
-
days_back:
|
|
16369
|
-
limit:
|
|
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
|
|
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:
|
|
16603
|
-
session_id:
|
|
16604
|
-
days_back:
|
|
16605
|
-
limit:
|
|
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
|
|
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:
|
|
16937
|
-
limit:
|
|
16938
|
-
exclude_linked:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
17184
|
-
target_path:
|
|
17185
|
-
source_name:
|
|
17186
|
-
target_name:
|
|
17187
|
-
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.
|
|
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.
|
|
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",
|