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