@velvetmonkey/flywheel-memory 2.0.9 → 2.0.11
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 +841 -82
- 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,105 @@ 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
|
+
function recordFeedback(stateDb2, entity, context, notePath, correct) {
|
|
2735
|
+
stateDb2.db.prepare(
|
|
2736
|
+
"INSERT INTO wikilink_feedback (entity, context, note_path, correct) VALUES (?, ?, ?, ?)"
|
|
2737
|
+
).run(entity, context, notePath, correct ? 1 : 0);
|
|
2738
|
+
}
|
|
2739
|
+
function getFeedback(stateDb2, entity, limit = 20) {
|
|
2740
|
+
let rows;
|
|
2741
|
+
if (entity) {
|
|
2742
|
+
rows = stateDb2.db.prepare(
|
|
2743
|
+
"SELECT id, entity, context, note_path, correct, created_at FROM wikilink_feedback WHERE entity = ? ORDER BY created_at DESC LIMIT ?"
|
|
2744
|
+
).all(entity, limit);
|
|
2745
|
+
} else {
|
|
2746
|
+
rows = stateDb2.db.prepare(
|
|
2747
|
+
"SELECT id, entity, context, note_path, correct, created_at FROM wikilink_feedback ORDER BY created_at DESC LIMIT ?"
|
|
2748
|
+
).all(limit);
|
|
2749
|
+
}
|
|
2750
|
+
return rows.map((r) => ({
|
|
2751
|
+
id: r.id,
|
|
2752
|
+
entity: r.entity,
|
|
2753
|
+
context: r.context,
|
|
2754
|
+
note_path: r.note_path,
|
|
2755
|
+
correct: r.correct === 1,
|
|
2756
|
+
created_at: r.created_at
|
|
2757
|
+
}));
|
|
2758
|
+
}
|
|
2759
|
+
function getEntityStats(stateDb2) {
|
|
2760
|
+
const rows = stateDb2.db.prepare(`
|
|
2761
|
+
SELECT
|
|
2762
|
+
entity,
|
|
2763
|
+
COUNT(*) as total,
|
|
2764
|
+
SUM(CASE WHEN correct = 1 THEN 1 ELSE 0 END) as correct_count,
|
|
2765
|
+
SUM(CASE WHEN correct = 0 THEN 1 ELSE 0 END) as incorrect_count
|
|
2766
|
+
FROM wikilink_feedback
|
|
2767
|
+
GROUP BY entity
|
|
2768
|
+
ORDER BY total DESC
|
|
2769
|
+
`).all();
|
|
2770
|
+
return rows.map((r) => {
|
|
2771
|
+
const suppressed = isSuppressed(stateDb2, r.entity);
|
|
2772
|
+
return {
|
|
2773
|
+
entity: r.entity,
|
|
2774
|
+
total: r.total,
|
|
2775
|
+
correct: r.correct_count,
|
|
2776
|
+
incorrect: r.incorrect_count,
|
|
2777
|
+
accuracy: r.total > 0 ? Math.round(r.correct_count / r.total * 1e3) / 1e3 : 0,
|
|
2778
|
+
suppressed
|
|
2779
|
+
};
|
|
2780
|
+
});
|
|
2781
|
+
}
|
|
2782
|
+
function updateSuppressionList(stateDb2) {
|
|
2783
|
+
const stats = stateDb2.db.prepare(`
|
|
2784
|
+
SELECT
|
|
2785
|
+
entity,
|
|
2786
|
+
COUNT(*) as total,
|
|
2787
|
+
SUM(CASE WHEN correct = 0 THEN 1 ELSE 0 END) as false_positives
|
|
2788
|
+
FROM wikilink_feedback
|
|
2789
|
+
GROUP BY entity
|
|
2790
|
+
HAVING total >= ?
|
|
2791
|
+
`).all(MIN_FEEDBACK_COUNT);
|
|
2792
|
+
let updated = 0;
|
|
2793
|
+
const upsert = stateDb2.db.prepare(`
|
|
2794
|
+
INSERT INTO wikilink_suppressions (entity, false_positive_rate, updated_at)
|
|
2795
|
+
VALUES (?, ?, datetime('now'))
|
|
2796
|
+
ON CONFLICT(entity) DO UPDATE SET
|
|
2797
|
+
false_positive_rate = excluded.false_positive_rate,
|
|
2798
|
+
updated_at = datetime('now')
|
|
2799
|
+
`);
|
|
2800
|
+
const remove = stateDb2.db.prepare(
|
|
2801
|
+
"DELETE FROM wikilink_suppressions WHERE entity = ?"
|
|
2802
|
+
);
|
|
2803
|
+
const transaction = stateDb2.db.transaction(() => {
|
|
2804
|
+
for (const stat3 of stats) {
|
|
2805
|
+
const fpRate = stat3.false_positives / stat3.total;
|
|
2806
|
+
if (fpRate >= SUPPRESSION_THRESHOLD) {
|
|
2807
|
+
upsert.run(stat3.entity, fpRate);
|
|
2808
|
+
updated++;
|
|
2809
|
+
} else {
|
|
2810
|
+
remove.run(stat3.entity);
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
});
|
|
2814
|
+
transaction();
|
|
2815
|
+
return updated;
|
|
2816
|
+
}
|
|
2817
|
+
function isSuppressed(stateDb2, entity) {
|
|
2818
|
+
const row = stateDb2.db.prepare(
|
|
2819
|
+
"SELECT entity FROM wikilink_suppressions WHERE entity = ?"
|
|
2820
|
+
).get(entity);
|
|
2821
|
+
return !!row;
|
|
2822
|
+
}
|
|
2823
|
+
function getSuppressedCount(stateDb2) {
|
|
2824
|
+
const row = stateDb2.db.prepare(
|
|
2825
|
+
"SELECT COUNT(*) as count FROM wikilink_suppressions"
|
|
2826
|
+
).get();
|
|
2827
|
+
return row.count;
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2731
2830
|
// src/core/write/git.ts
|
|
2732
2831
|
import { simpleGit, CheckRepoActions } from "simple-git";
|
|
2733
2832
|
import path5 from "path";
|
|
@@ -4218,8 +4317,14 @@ function processWikilinks(content, notePath) {
|
|
|
4218
4317
|
linkedEntities: []
|
|
4219
4318
|
};
|
|
4220
4319
|
}
|
|
4221
|
-
|
|
4320
|
+
let entities = getAllEntities(entityIndex);
|
|
4222
4321
|
console.error(`[Flywheel:DEBUG] Processing wikilinks with ${entities.length} entities`);
|
|
4322
|
+
if (moduleStateDb4) {
|
|
4323
|
+
entities = entities.filter((e) => {
|
|
4324
|
+
const name = getEntityName2(e);
|
|
4325
|
+
return !isSuppressed(moduleStateDb4, name);
|
|
4326
|
+
});
|
|
4327
|
+
}
|
|
4223
4328
|
const sortedEntities = sortEntitiesByPriority(entities, notePath);
|
|
4224
4329
|
const resolved = resolveAliasWikilinks(content, sortedEntities, {
|
|
4225
4330
|
caseInsensitive: true
|
|
@@ -5023,6 +5128,82 @@ function getLinkPath(index, fromPath, toPath, maxDepth = 10) {
|
|
|
5023
5128
|
}
|
|
5024
5129
|
return { exists: false, path: [], length: -1 };
|
|
5025
5130
|
}
|
|
5131
|
+
function getWeightedLinkPath(index, fromPath, toPath, maxDepth = 10) {
|
|
5132
|
+
const from = index.notes.has(fromPath) ? fromPath : resolveTarget(index, fromPath);
|
|
5133
|
+
const to = index.notes.has(toPath) ? toPath : resolveTarget(index, toPath);
|
|
5134
|
+
if (!from || !to) {
|
|
5135
|
+
return { exists: false, path: [], length: -1, total_weight: 0, weights: [] };
|
|
5136
|
+
}
|
|
5137
|
+
if (from === to) {
|
|
5138
|
+
return { exists: true, path: [from], length: 0, total_weight: 0, weights: [] };
|
|
5139
|
+
}
|
|
5140
|
+
const hubNotes = findHubNotes(index, 0);
|
|
5141
|
+
const connectionCounts = /* @__PURE__ */ new Map();
|
|
5142
|
+
for (const hub of hubNotes) {
|
|
5143
|
+
connectionCounts.set(hub.path, hub.total_connections);
|
|
5144
|
+
}
|
|
5145
|
+
const dist = /* @__PURE__ */ new Map();
|
|
5146
|
+
const prev = /* @__PURE__ */ new Map();
|
|
5147
|
+
const depthMap = /* @__PURE__ */ new Map();
|
|
5148
|
+
dist.set(from, 0);
|
|
5149
|
+
depthMap.set(from, 0);
|
|
5150
|
+
const pq = [{ node: from, cost: 0 }];
|
|
5151
|
+
const visited = /* @__PURE__ */ new Set();
|
|
5152
|
+
while (pq.length > 0) {
|
|
5153
|
+
const { node: current, cost: currentCost } = pq.shift();
|
|
5154
|
+
if (current === to) break;
|
|
5155
|
+
if (visited.has(current)) continue;
|
|
5156
|
+
visited.add(current);
|
|
5157
|
+
const currentDepth = depthMap.get(current) ?? 0;
|
|
5158
|
+
if (currentDepth >= maxDepth) continue;
|
|
5159
|
+
const note = index.notes.get(current);
|
|
5160
|
+
if (!note) continue;
|
|
5161
|
+
for (const link of note.outlinks) {
|
|
5162
|
+
const targetPath = resolveTarget(index, link.target);
|
|
5163
|
+
if (!targetPath || visited.has(targetPath)) continue;
|
|
5164
|
+
const strength = getConnectionStrength(index, current, targetPath);
|
|
5165
|
+
const baseWeight = 1 / (1 + strength.score);
|
|
5166
|
+
const targetConnections = connectionCounts.get(targetPath) ?? 0;
|
|
5167
|
+
const hubPenalty = targetConnections > 0 ? 1 + Math.log2(targetConnections) : 1;
|
|
5168
|
+
const edgeWeight = baseWeight * hubPenalty;
|
|
5169
|
+
const newDist = currentCost + edgeWeight;
|
|
5170
|
+
const existingDist = dist.get(targetPath);
|
|
5171
|
+
if (existingDist === void 0 || newDist < existingDist) {
|
|
5172
|
+
dist.set(targetPath, newDist);
|
|
5173
|
+
prev.set(targetPath, current);
|
|
5174
|
+
depthMap.set(targetPath, currentDepth + 1);
|
|
5175
|
+
const entry = { node: targetPath, cost: newDist };
|
|
5176
|
+
const insertIdx = pq.findIndex((e) => e.cost > newDist);
|
|
5177
|
+
if (insertIdx === -1) {
|
|
5178
|
+
pq.push(entry);
|
|
5179
|
+
} else {
|
|
5180
|
+
pq.splice(insertIdx, 0, entry);
|
|
5181
|
+
}
|
|
5182
|
+
}
|
|
5183
|
+
}
|
|
5184
|
+
}
|
|
5185
|
+
if (!dist.has(to)) {
|
|
5186
|
+
return { exists: false, path: [], length: -1, total_weight: 0, weights: [] };
|
|
5187
|
+
}
|
|
5188
|
+
const resultPath = [];
|
|
5189
|
+
let node = to;
|
|
5190
|
+
while (node) {
|
|
5191
|
+
resultPath.unshift(node);
|
|
5192
|
+
node = prev.get(node);
|
|
5193
|
+
}
|
|
5194
|
+
const weights = [];
|
|
5195
|
+
for (let i = 0; i < resultPath.length - 1; i++) {
|
|
5196
|
+
const edgeDist = (dist.get(resultPath[i + 1]) ?? 0) - (dist.get(resultPath[i]) ?? 0);
|
|
5197
|
+
weights.push(Math.round(edgeDist * 1e3) / 1e3);
|
|
5198
|
+
}
|
|
5199
|
+
return {
|
|
5200
|
+
exists: true,
|
|
5201
|
+
path: resultPath,
|
|
5202
|
+
length: resultPath.length - 1,
|
|
5203
|
+
total_weight: Math.round((dist.get(to) ?? 0) * 1e3) / 1e3,
|
|
5204
|
+
weights
|
|
5205
|
+
};
|
|
5206
|
+
}
|
|
5026
5207
|
function getCommonNeighbors(index, noteAPath, noteBPath) {
|
|
5027
5208
|
const noteA = index.notes.get(noteAPath);
|
|
5028
5209
|
const noteB = index.notes.get(noteBPath);
|
|
@@ -5477,14 +5658,14 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
|
|
|
5477
5658
|
};
|
|
5478
5659
|
function findSimilarEntity2(target, entities) {
|
|
5479
5660
|
const targetLower = target.toLowerCase();
|
|
5480
|
-
for (const [name,
|
|
5661
|
+
for (const [name, path25] of entities) {
|
|
5481
5662
|
if (name.startsWith(targetLower) || targetLower.startsWith(name)) {
|
|
5482
|
-
return
|
|
5663
|
+
return path25;
|
|
5483
5664
|
}
|
|
5484
5665
|
}
|
|
5485
|
-
for (const [name,
|
|
5666
|
+
for (const [name, path25] of entities) {
|
|
5486
5667
|
if (name.includes(targetLower) || targetLower.includes(name)) {
|
|
5487
|
-
return
|
|
5668
|
+
return path25;
|
|
5488
5669
|
}
|
|
5489
5670
|
}
|
|
5490
5671
|
return void 0;
|
|
@@ -5961,8 +6142,8 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
|
|
|
5961
6142
|
daily_counts: z3.record(z3.number())
|
|
5962
6143
|
}).describe("Activity summary for the last 7 days")
|
|
5963
6144
|
};
|
|
5964
|
-
function isPeriodicNote(
|
|
5965
|
-
const filename =
|
|
6145
|
+
function isPeriodicNote(path25) {
|
|
6146
|
+
const filename = path25.split("/").pop() || "";
|
|
5966
6147
|
const nameWithoutExt = filename.replace(/\.md$/, "");
|
|
5967
6148
|
const patterns = [
|
|
5968
6149
|
/^\d{4}-\d{2}-\d{2}$/,
|
|
@@ -5977,7 +6158,7 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
|
|
|
5977
6158
|
// YYYY (yearly)
|
|
5978
6159
|
];
|
|
5979
6160
|
const periodicFolders = ["daily", "weekly", "monthly", "quarterly", "yearly", "journal", "journals"];
|
|
5980
|
-
const folder =
|
|
6161
|
+
const folder = path25.split("/")[0]?.toLowerCase() || "";
|
|
5981
6162
|
return patterns.some((p) => p.test(nameWithoutExt)) || periodicFolders.includes(folder);
|
|
5982
6163
|
}
|
|
5983
6164
|
server2.registerTool(
|
|
@@ -6103,15 +6284,20 @@ function matchesFrontmatter(note, where) {
|
|
|
6103
6284
|
}
|
|
6104
6285
|
return true;
|
|
6105
6286
|
}
|
|
6106
|
-
function hasTag(note, tag) {
|
|
6287
|
+
function hasTag(note, tag, includeChildren = false) {
|
|
6107
6288
|
const normalizedTag = tag.replace(/^#/, "").toLowerCase();
|
|
6108
|
-
return note.tags.some((t) =>
|
|
6289
|
+
return note.tags.some((t) => {
|
|
6290
|
+
const normalizedNoteTag = t.toLowerCase();
|
|
6291
|
+
if (normalizedNoteTag === normalizedTag) return true;
|
|
6292
|
+
if (includeChildren && normalizedNoteTag.startsWith(normalizedTag + "/")) return true;
|
|
6293
|
+
return false;
|
|
6294
|
+
});
|
|
6109
6295
|
}
|
|
6110
|
-
function hasAnyTag(note, tags) {
|
|
6111
|
-
return tags.some((tag) => hasTag(note, tag));
|
|
6296
|
+
function hasAnyTag(note, tags, includeChildren = false) {
|
|
6297
|
+
return tags.some((tag) => hasTag(note, tag, includeChildren));
|
|
6112
6298
|
}
|
|
6113
|
-
function hasAllTags(note, tags) {
|
|
6114
|
-
return tags.every((tag) => hasTag(note, tag));
|
|
6299
|
+
function hasAllTags(note, tags, includeChildren = false) {
|
|
6300
|
+
return tags.every((tag) => hasTag(note, tag, includeChildren));
|
|
6115
6301
|
}
|
|
6116
6302
|
function inFolder(note, folder) {
|
|
6117
6303
|
const normalizedFolder = folder.endsWith("/") ? folder : folder + "/";
|
|
@@ -6150,6 +6336,7 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
|
|
|
6150
6336
|
has_tag: z4.string().optional().describe("Filter to notes with this tag"),
|
|
6151
6337
|
has_any_tag: z4.array(z4.string()).optional().describe("Filter to notes with any of these tags"),
|
|
6152
6338
|
has_all_tags: z4.array(z4.string()).optional().describe("Filter to notes with all of these tags"),
|
|
6339
|
+
include_children: z4.boolean().default(false).describe('When true, tag filters also match child tags (e.g., has_tag: "project" also matches "project/active")'),
|
|
6153
6340
|
folder: z4.string().optional().describe("Limit to notes in this folder"),
|
|
6154
6341
|
title_contains: z4.string().optional().describe("Filter to notes whose title contains this text (case-insensitive)"),
|
|
6155
6342
|
// Date filters (absorbs temporal tools)
|
|
@@ -6163,7 +6350,7 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
|
|
|
6163
6350
|
// Pagination
|
|
6164
6351
|
limit: z4.number().default(20).describe("Maximum number of results to return")
|
|
6165
6352
|
},
|
|
6166
|
-
async ({ query, scope, where, has_tag, has_any_tag, has_all_tags, folder, title_contains, modified_after, modified_before, sort_by, order, prefix, limit: requestedLimit }) => {
|
|
6353
|
+
async ({ query, scope, where, has_tag, has_any_tag, has_all_tags, include_children, folder, title_contains, modified_after, modified_before, sort_by, order, prefix, limit: requestedLimit }) => {
|
|
6167
6354
|
const limit = Math.min(requestedLimit ?? 20, MAX_LIMIT);
|
|
6168
6355
|
const index = getIndex();
|
|
6169
6356
|
const vaultPath2 = getVaultPath();
|
|
@@ -6189,13 +6376,13 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
|
|
|
6189
6376
|
matchingNotes = matchingNotes.filter((note) => matchesFrontmatter(note, where));
|
|
6190
6377
|
}
|
|
6191
6378
|
if (has_tag) {
|
|
6192
|
-
matchingNotes = matchingNotes.filter((note) => hasTag(note, has_tag));
|
|
6379
|
+
matchingNotes = matchingNotes.filter((note) => hasTag(note, has_tag, include_children));
|
|
6193
6380
|
}
|
|
6194
6381
|
if (has_any_tag && has_any_tag.length > 0) {
|
|
6195
|
-
matchingNotes = matchingNotes.filter((note) => hasAnyTag(note, has_any_tag));
|
|
6382
|
+
matchingNotes = matchingNotes.filter((note) => hasAnyTag(note, has_any_tag, include_children));
|
|
6196
6383
|
}
|
|
6197
6384
|
if (has_all_tags && has_all_tags.length > 0) {
|
|
6198
|
-
matchingNotes = matchingNotes.filter((note) => hasAllTags(note, has_all_tags));
|
|
6385
|
+
matchingNotes = matchingNotes.filter((note) => hasAllTags(note, has_all_tags, include_children));
|
|
6199
6386
|
}
|
|
6200
6387
|
if (folder) {
|
|
6201
6388
|
matchingNotes = matchingNotes.filter((note) => inFolder(note, folder));
|
|
@@ -6976,18 +7163,18 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
|
|
|
6976
7163
|
include_content: z6.boolean().default(false).describe("Include the text content under each top-level section")
|
|
6977
7164
|
}
|
|
6978
7165
|
},
|
|
6979
|
-
async ({ path:
|
|
7166
|
+
async ({ path: path25, include_content }) => {
|
|
6980
7167
|
const index = getIndex();
|
|
6981
7168
|
const vaultPath2 = getVaultPath();
|
|
6982
|
-
const result = await getNoteStructure(index,
|
|
7169
|
+
const result = await getNoteStructure(index, path25, vaultPath2);
|
|
6983
7170
|
if (!result) {
|
|
6984
7171
|
return {
|
|
6985
|
-
content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path:
|
|
7172
|
+
content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path25 }, null, 2) }]
|
|
6986
7173
|
};
|
|
6987
7174
|
}
|
|
6988
7175
|
if (include_content) {
|
|
6989
7176
|
for (const section of result.sections) {
|
|
6990
|
-
const sectionResult = await getSectionContent(index,
|
|
7177
|
+
const sectionResult = await getSectionContent(index, path25, section.heading.text, vaultPath2, true);
|
|
6991
7178
|
if (sectionResult) {
|
|
6992
7179
|
section.content = sectionResult.content;
|
|
6993
7180
|
}
|
|
@@ -7009,15 +7196,15 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
|
|
|
7009
7196
|
include_subheadings: z6.boolean().default(true).describe("Include content under subheadings")
|
|
7010
7197
|
}
|
|
7011
7198
|
},
|
|
7012
|
-
async ({ path:
|
|
7199
|
+
async ({ path: path25, heading, include_subheadings }) => {
|
|
7013
7200
|
const index = getIndex();
|
|
7014
7201
|
const vaultPath2 = getVaultPath();
|
|
7015
|
-
const result = await getSectionContent(index,
|
|
7202
|
+
const result = await getSectionContent(index, path25, heading, vaultPath2, include_subheadings);
|
|
7016
7203
|
if (!result) {
|
|
7017
7204
|
return {
|
|
7018
7205
|
content: [{ type: "text", text: JSON.stringify({
|
|
7019
7206
|
error: "Section not found",
|
|
7020
|
-
path:
|
|
7207
|
+
path: path25,
|
|
7021
7208
|
heading
|
|
7022
7209
|
}, null, 2) }]
|
|
7023
7210
|
};
|
|
@@ -7071,16 +7258,16 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
|
|
|
7071
7258
|
offset: z6.coerce.number().default(0).describe("Number of results to skip (for pagination)")
|
|
7072
7259
|
}
|
|
7073
7260
|
},
|
|
7074
|
-
async ({ path:
|
|
7261
|
+
async ({ path: path25, status, has_due_date, folder, tag, limit: requestedLimit, offset }) => {
|
|
7075
7262
|
const limit = Math.min(requestedLimit ?? 25, MAX_LIMIT);
|
|
7076
7263
|
const index = getIndex();
|
|
7077
7264
|
const vaultPath2 = getVaultPath();
|
|
7078
7265
|
const config = getConfig();
|
|
7079
|
-
if (
|
|
7080
|
-
const result2 = await getTasksFromNote(index,
|
|
7266
|
+
if (path25) {
|
|
7267
|
+
const result2 = await getTasksFromNote(index, path25, vaultPath2, config.exclude_task_tags || []);
|
|
7081
7268
|
if (!result2) {
|
|
7082
7269
|
return {
|
|
7083
|
-
content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path:
|
|
7270
|
+
content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path25 }, null, 2) }]
|
|
7084
7271
|
};
|
|
7085
7272
|
}
|
|
7086
7273
|
let filtered = result2;
|
|
@@ -7090,7 +7277,7 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
|
|
|
7090
7277
|
const paged2 = filtered.slice(offset, offset + limit);
|
|
7091
7278
|
return {
|
|
7092
7279
|
content: [{ type: "text", text: JSON.stringify({
|
|
7093
|
-
path:
|
|
7280
|
+
path: path25,
|
|
7094
7281
|
total_count: filtered.length,
|
|
7095
7282
|
returned_count: paged2.length,
|
|
7096
7283
|
open: result2.filter((t) => t.status === "open").length,
|
|
@@ -7136,16 +7323,17 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
|
|
|
7136
7323
|
"get_link_path",
|
|
7137
7324
|
{
|
|
7138
7325
|
title: "Get Link Path",
|
|
7139
|
-
description: "Find the shortest path of links between two notes.",
|
|
7326
|
+
description: "Find the shortest path of links between two notes. Use weighted=true to penalize hub nodes for more meaningful paths.",
|
|
7140
7327
|
inputSchema: {
|
|
7141
7328
|
from: z6.string().describe("Starting note path"),
|
|
7142
7329
|
to: z6.string().describe("Target note path"),
|
|
7143
|
-
max_depth: z6.coerce.number().default(10).describe("Maximum path length to search")
|
|
7330
|
+
max_depth: z6.coerce.number().default(10).describe("Maximum path length to search"),
|
|
7331
|
+
weighted: z6.boolean().default(false).describe("Use weighted path-finding that penalizes hub nodes for more meaningful paths")
|
|
7144
7332
|
}
|
|
7145
7333
|
},
|
|
7146
|
-
async ({ from, to, max_depth }) => {
|
|
7334
|
+
async ({ from, to, max_depth, weighted }) => {
|
|
7147
7335
|
const index = getIndex();
|
|
7148
|
-
const result = getLinkPath(index, from, to, max_depth);
|
|
7336
|
+
const result = weighted ? getWeightedLinkPath(index, from, to, max_depth) : getLinkPath(index, from, to, max_depth);
|
|
7149
7337
|
return {
|
|
7150
7338
|
content: [{ type: "text", text: JSON.stringify({
|
|
7151
7339
|
from,
|
|
@@ -9503,6 +9691,7 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
|
|
|
9503
9691
|
{
|
|
9504
9692
|
path: z14.string().describe('Vault-relative path for the new note (e.g., "daily-notes/2026-01-28.md")'),
|
|
9505
9693
|
content: z14.string().default("").describe("Initial content for the note"),
|
|
9694
|
+
template: z14.string().optional().describe('Vault-relative path to a template file (e.g., "templates/person.md"). Template variables {{date}} and {{title}} are substituted. Template frontmatter is merged with the frontmatter parameter (explicit values take precedence).'),
|
|
9506
9695
|
frontmatter: z14.record(z14.any()).default({}).describe("Frontmatter fields (JSON object)"),
|
|
9507
9696
|
overwrite: z14.boolean().default(false).describe("If true, overwrite existing file"),
|
|
9508
9697
|
commit: z14.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
|
|
@@ -9512,7 +9701,7 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
|
|
|
9512
9701
|
agent_id: z14.string().optional().describe("Agent identifier for multi-agent scoping"),
|
|
9513
9702
|
session_id: z14.string().optional().describe("Session identifier for conversation scoping")
|
|
9514
9703
|
},
|
|
9515
|
-
async ({ path: notePath, content, frontmatter, overwrite, commit, skipWikilinks, suggestOutgoingLinks, maxSuggestions, agent_id, session_id }) => {
|
|
9704
|
+
async ({ path: notePath, content, template, frontmatter, overwrite, commit, skipWikilinks, suggestOutgoingLinks, maxSuggestions, agent_id, session_id }) => {
|
|
9516
9705
|
try {
|
|
9517
9706
|
if (!validatePath(vaultPath2, notePath)) {
|
|
9518
9707
|
return formatMcpResult(errorResult(notePath, "Invalid path: path traversal not allowed"));
|
|
@@ -9524,9 +9713,29 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
|
|
|
9524
9713
|
}
|
|
9525
9714
|
const dir = path18.dirname(fullPath);
|
|
9526
9715
|
await fs18.mkdir(dir, { recursive: true });
|
|
9716
|
+
let effectiveContent = content;
|
|
9717
|
+
let effectiveFrontmatter = frontmatter;
|
|
9718
|
+
if (template) {
|
|
9719
|
+
const templatePath = path18.join(vaultPath2, template);
|
|
9720
|
+
try {
|
|
9721
|
+
const raw = await fs18.readFile(templatePath, "utf-8");
|
|
9722
|
+
const matter9 = (await import("gray-matter")).default;
|
|
9723
|
+
const parsed = matter9(raw);
|
|
9724
|
+
const dateStr = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
9725
|
+
const title = path18.basename(notePath, ".md");
|
|
9726
|
+
let templateContent = parsed.content.replace(/\{\{date\}\}/g, dateStr).replace(/\{\{title\}\}/g, title);
|
|
9727
|
+
if (content) {
|
|
9728
|
+
templateContent = templateContent.trimEnd() + "\n\n" + content;
|
|
9729
|
+
}
|
|
9730
|
+
effectiveContent = templateContent;
|
|
9731
|
+
effectiveFrontmatter = { ...parsed.data || {}, ...frontmatter };
|
|
9732
|
+
} catch {
|
|
9733
|
+
return formatMcpResult(errorResult(notePath, `Template not found: ${template}`));
|
|
9734
|
+
}
|
|
9735
|
+
}
|
|
9527
9736
|
const warnings = [];
|
|
9528
9737
|
const noteName = path18.basename(notePath, ".md");
|
|
9529
|
-
const existingAliases = Array.isArray(
|
|
9738
|
+
const existingAliases = Array.isArray(effectiveFrontmatter?.aliases) ? effectiveFrontmatter.aliases.filter((a) => typeof a === "string") : [];
|
|
9530
9739
|
const preflight = checkPreflightSimilarity(noteName);
|
|
9531
9740
|
if (preflight.existingEntity) {
|
|
9532
9741
|
warnings.push({
|
|
@@ -9550,7 +9759,7 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
|
|
|
9550
9759
|
suggestion: `This may cause ambiguous wikilink resolution`
|
|
9551
9760
|
});
|
|
9552
9761
|
}
|
|
9553
|
-
let { content: processedContent, wikilinkInfo } = maybeApplyWikilinks(
|
|
9762
|
+
let { content: processedContent, wikilinkInfo } = maybeApplyWikilinks(effectiveContent, skipWikilinks, notePath);
|
|
9554
9763
|
let suggestInfo;
|
|
9555
9764
|
if (suggestOutgoingLinks && !skipWikilinks) {
|
|
9556
9765
|
const result = suggestRelatedLinks(processedContent, { maxSuggestions, notePath });
|
|
@@ -9559,21 +9768,21 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
|
|
|
9559
9768
|
suggestInfo = `Suggested: ${result.suggestions.join(", ")}`;
|
|
9560
9769
|
}
|
|
9561
9770
|
}
|
|
9562
|
-
let finalFrontmatter =
|
|
9771
|
+
let finalFrontmatter = effectiveFrontmatter;
|
|
9563
9772
|
if (agent_id || session_id) {
|
|
9564
|
-
finalFrontmatter = injectMutationMetadata(
|
|
9773
|
+
finalFrontmatter = injectMutationMetadata(effectiveFrontmatter, { agent_id, session_id });
|
|
9565
9774
|
}
|
|
9566
9775
|
await writeVaultFile(vaultPath2, notePath, processedContent, finalFrontmatter);
|
|
9567
9776
|
const gitInfo = await handleGitCommit(vaultPath2, notePath, commit, "[Flywheel:Create]");
|
|
9568
9777
|
const infoLines = [wikilinkInfo, suggestInfo].filter(Boolean);
|
|
9569
9778
|
const previewLines = [
|
|
9570
|
-
`Frontmatter fields: ${Object.keys(
|
|
9779
|
+
`Frontmatter fields: ${Object.keys(effectiveFrontmatter).join(", ") || "none"}`,
|
|
9571
9780
|
`Content length: ${processedContent.length} chars`
|
|
9572
9781
|
];
|
|
9573
9782
|
if (infoLines.length > 0) {
|
|
9574
9783
|
previewLines.push(`(${infoLines.join("; ")})`);
|
|
9575
9784
|
}
|
|
9576
|
-
const hasAliases =
|
|
9785
|
+
const hasAliases = effectiveFrontmatter && "aliases" in effectiveFrontmatter;
|
|
9577
9786
|
if (!hasAliases) {
|
|
9578
9787
|
const aliasSuggestions = suggestAliases(noteName, existingAliases);
|
|
9579
9788
|
if (aliasSuggestions.length > 0) {
|
|
@@ -11476,6 +11685,531 @@ function registerPolicyTools(server2, vaultPath2) {
|
|
|
11476
11685
|
);
|
|
11477
11686
|
}
|
|
11478
11687
|
|
|
11688
|
+
// src/tools/write/tags.ts
|
|
11689
|
+
import { z as z19 } from "zod";
|
|
11690
|
+
|
|
11691
|
+
// src/core/write/tagRename.ts
|
|
11692
|
+
import * as fs24 from "fs/promises";
|
|
11693
|
+
import * as path24 from "path";
|
|
11694
|
+
import matter8 from "gray-matter";
|
|
11695
|
+
import { getProtectedZones } from "@velvetmonkey/vault-core";
|
|
11696
|
+
function getNotesInFolder3(index, folder) {
|
|
11697
|
+
const notes = [];
|
|
11698
|
+
for (const note of index.notes.values()) {
|
|
11699
|
+
const noteFolder = note.path.includes("/") ? note.path.substring(0, note.path.lastIndexOf("/")) : "";
|
|
11700
|
+
if (!folder || note.path.startsWith(folder + "/") || noteFolder === folder) {
|
|
11701
|
+
notes.push(note);
|
|
11702
|
+
}
|
|
11703
|
+
}
|
|
11704
|
+
return notes;
|
|
11705
|
+
}
|
|
11706
|
+
function tagMatches(tag, oldTag, renameChildren) {
|
|
11707
|
+
const tagLower = tag.toLowerCase();
|
|
11708
|
+
const oldLower = oldTag.toLowerCase();
|
|
11709
|
+
if (tagLower === oldLower) return true;
|
|
11710
|
+
if (renameChildren && tagLower.startsWith(oldLower + "/")) return true;
|
|
11711
|
+
return false;
|
|
11712
|
+
}
|
|
11713
|
+
function transformTag(tag, oldTag, newTag) {
|
|
11714
|
+
const tagLower = tag.toLowerCase();
|
|
11715
|
+
const oldLower = oldTag.toLowerCase();
|
|
11716
|
+
if (tagLower === oldLower) {
|
|
11717
|
+
return newTag;
|
|
11718
|
+
}
|
|
11719
|
+
if (tagLower.startsWith(oldLower + "/")) {
|
|
11720
|
+
const suffix = tag.substring(oldTag.length);
|
|
11721
|
+
return newTag + suffix;
|
|
11722
|
+
}
|
|
11723
|
+
return tag;
|
|
11724
|
+
}
|
|
11725
|
+
function isProtected(start, end, zones) {
|
|
11726
|
+
for (const zone of zones) {
|
|
11727
|
+
if (zone.type === "hashtag") continue;
|
|
11728
|
+
if (zone.type === "frontmatter") continue;
|
|
11729
|
+
if (start >= zone.start && start < zone.end || end > zone.start && end <= zone.end || start <= zone.start && end >= zone.end) {
|
|
11730
|
+
return true;
|
|
11731
|
+
}
|
|
11732
|
+
}
|
|
11733
|
+
return false;
|
|
11734
|
+
}
|
|
11735
|
+
function replaceInlineTags(content, oldTag, newTag, renameChildren) {
|
|
11736
|
+
const zones = getProtectedZones(content);
|
|
11737
|
+
const changes = [];
|
|
11738
|
+
const escapedOld = oldTag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
11739
|
+
const pattern = renameChildren ? new RegExp(`(^|\\s)#(${escapedOld}(?:/[a-zA-Z0-9_/-]*)?)(?=[\\s,;.!?)]|$)`, "gim") : new RegExp(`(^|\\s)#(${escapedOld})(?=[/\\s,;.!?)]|$)`, "gim");
|
|
11740
|
+
const lineStarts = [0];
|
|
11741
|
+
for (let i = 0; i < content.length; i++) {
|
|
11742
|
+
if (content[i] === "\n") lineStarts.push(i + 1);
|
|
11743
|
+
}
|
|
11744
|
+
function getLineNumber(pos) {
|
|
11745
|
+
for (let i = lineStarts.length - 1; i >= 0; i--) {
|
|
11746
|
+
if (pos >= lineStarts[i]) return i + 1;
|
|
11747
|
+
}
|
|
11748
|
+
return 1;
|
|
11749
|
+
}
|
|
11750
|
+
const matches = [];
|
|
11751
|
+
let match;
|
|
11752
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
11753
|
+
const prefix = match[1];
|
|
11754
|
+
const matchedTag = match[2];
|
|
11755
|
+
const tagStart = match.index + prefix.length + 1;
|
|
11756
|
+
const tagEnd = tagStart + matchedTag.length;
|
|
11757
|
+
if (isProtected(match.index, tagEnd, zones)) continue;
|
|
11758
|
+
if (!tagMatches(matchedTag, oldTag, renameChildren)) continue;
|
|
11759
|
+
matches.push({
|
|
11760
|
+
index: match.index,
|
|
11761
|
+
fullMatch: match[0],
|
|
11762
|
+
prefix,
|
|
11763
|
+
matchedTag
|
|
11764
|
+
});
|
|
11765
|
+
}
|
|
11766
|
+
let result = content;
|
|
11767
|
+
for (let i = matches.length - 1; i >= 0; i--) {
|
|
11768
|
+
const m = matches[i];
|
|
11769
|
+
const transformed = transformTag(m.matchedTag, oldTag, newTag);
|
|
11770
|
+
const replacement = m.prefix + "#" + transformed;
|
|
11771
|
+
const start = m.index;
|
|
11772
|
+
const end = start + m.fullMatch.length;
|
|
11773
|
+
result = result.substring(0, start) + replacement + result.substring(end);
|
|
11774
|
+
changes.unshift({
|
|
11775
|
+
old: "#" + m.matchedTag,
|
|
11776
|
+
new: "#" + transformed,
|
|
11777
|
+
line: getLineNumber(start)
|
|
11778
|
+
});
|
|
11779
|
+
}
|
|
11780
|
+
return { content: result, changes };
|
|
11781
|
+
}
|
|
11782
|
+
async function renameTag(index, vaultPath2, oldTag, newTag, options) {
|
|
11783
|
+
const renameChildren = options?.rename_children ?? true;
|
|
11784
|
+
const dryRun = options?.dry_run ?? true;
|
|
11785
|
+
const folder = options?.folder;
|
|
11786
|
+
const cleanOld = oldTag.replace(/^#/, "");
|
|
11787
|
+
const cleanNew = newTag.replace(/^#/, "");
|
|
11788
|
+
const notes = getNotesInFolder3(index, folder);
|
|
11789
|
+
const affectedNotes = [];
|
|
11790
|
+
for (const note of notes) {
|
|
11791
|
+
const hasTag2 = note.tags.some((t) => tagMatches(t, cleanOld, renameChildren));
|
|
11792
|
+
if (hasTag2) {
|
|
11793
|
+
affectedNotes.push(note);
|
|
11794
|
+
}
|
|
11795
|
+
}
|
|
11796
|
+
const previews = [];
|
|
11797
|
+
let totalChanges = 0;
|
|
11798
|
+
for (const note of affectedNotes) {
|
|
11799
|
+
const fullPath = path24.join(vaultPath2, note.path);
|
|
11800
|
+
let fileContent;
|
|
11801
|
+
try {
|
|
11802
|
+
fileContent = await fs24.readFile(fullPath, "utf-8");
|
|
11803
|
+
} catch {
|
|
11804
|
+
continue;
|
|
11805
|
+
}
|
|
11806
|
+
const preview = {
|
|
11807
|
+
path: note.path,
|
|
11808
|
+
frontmatter_changes: [],
|
|
11809
|
+
content_changes: [],
|
|
11810
|
+
total_changes: 0
|
|
11811
|
+
};
|
|
11812
|
+
let parsed;
|
|
11813
|
+
try {
|
|
11814
|
+
parsed = matter8(fileContent);
|
|
11815
|
+
} catch {
|
|
11816
|
+
continue;
|
|
11817
|
+
}
|
|
11818
|
+
const fm = parsed.data;
|
|
11819
|
+
let fmChanged = false;
|
|
11820
|
+
if (Array.isArray(fm.tags)) {
|
|
11821
|
+
const newTags = [];
|
|
11822
|
+
const seen = /* @__PURE__ */ new Set();
|
|
11823
|
+
for (const tag of fm.tags) {
|
|
11824
|
+
if (typeof tag !== "string") continue;
|
|
11825
|
+
const stripped = tag.replace(/^#/, "");
|
|
11826
|
+
if (!tagMatches(stripped, cleanOld, renameChildren)) {
|
|
11827
|
+
seen.add(stripped.toLowerCase());
|
|
11828
|
+
}
|
|
11829
|
+
}
|
|
11830
|
+
for (const tag of fm.tags) {
|
|
11831
|
+
if (typeof tag !== "string") {
|
|
11832
|
+
newTags.push(tag);
|
|
11833
|
+
continue;
|
|
11834
|
+
}
|
|
11835
|
+
const stripped = tag.replace(/^#/, "");
|
|
11836
|
+
if (tagMatches(stripped, cleanOld, renameChildren)) {
|
|
11837
|
+
const transformed = transformTag(stripped, cleanOld, cleanNew);
|
|
11838
|
+
const key = transformed.toLowerCase();
|
|
11839
|
+
if (seen.has(key)) {
|
|
11840
|
+
preview.frontmatter_changes.push({
|
|
11841
|
+
old: stripped,
|
|
11842
|
+
new: `${transformed} (merged)`
|
|
11843
|
+
});
|
|
11844
|
+
fmChanged = true;
|
|
11845
|
+
continue;
|
|
11846
|
+
}
|
|
11847
|
+
seen.add(key);
|
|
11848
|
+
preview.frontmatter_changes.push({
|
|
11849
|
+
old: stripped,
|
|
11850
|
+
new: transformed
|
|
11851
|
+
});
|
|
11852
|
+
newTags.push(transformed);
|
|
11853
|
+
fmChanged = true;
|
|
11854
|
+
} else {
|
|
11855
|
+
newTags.push(tag);
|
|
11856
|
+
}
|
|
11857
|
+
}
|
|
11858
|
+
if (fmChanged) {
|
|
11859
|
+
fm.tags = newTags;
|
|
11860
|
+
}
|
|
11861
|
+
}
|
|
11862
|
+
const { content: updatedContent, changes: contentChanges } = replaceInlineTags(
|
|
11863
|
+
parsed.content,
|
|
11864
|
+
cleanOld,
|
|
11865
|
+
cleanNew,
|
|
11866
|
+
renameChildren
|
|
11867
|
+
);
|
|
11868
|
+
preview.content_changes = contentChanges;
|
|
11869
|
+
preview.total_changes = preview.frontmatter_changes.length + preview.content_changes.length;
|
|
11870
|
+
totalChanges += preview.total_changes;
|
|
11871
|
+
if (preview.total_changes > 0) {
|
|
11872
|
+
previews.push(preview);
|
|
11873
|
+
if (!dryRun) {
|
|
11874
|
+
const newContent = matter8.stringify(updatedContent, fm);
|
|
11875
|
+
await fs24.writeFile(fullPath, newContent, "utf-8");
|
|
11876
|
+
}
|
|
11877
|
+
}
|
|
11878
|
+
}
|
|
11879
|
+
return {
|
|
11880
|
+
old_tag: cleanOld,
|
|
11881
|
+
new_tag: cleanNew,
|
|
11882
|
+
rename_children: renameChildren,
|
|
11883
|
+
dry_run: dryRun,
|
|
11884
|
+
affected_notes: previews.length,
|
|
11885
|
+
total_changes: totalChanges,
|
|
11886
|
+
previews
|
|
11887
|
+
};
|
|
11888
|
+
}
|
|
11889
|
+
|
|
11890
|
+
// src/tools/write/tags.ts
|
|
11891
|
+
function registerTagTools(server2, getIndex, getVaultPath) {
|
|
11892
|
+
server2.registerTool(
|
|
11893
|
+
"rename_tag",
|
|
11894
|
+
{
|
|
11895
|
+
title: "Rename Tag",
|
|
11896
|
+
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.",
|
|
11897
|
+
inputSchema: {
|
|
11898
|
+
old_tag: z19.string().describe('Tag to rename (without #, e.g., "project")'),
|
|
11899
|
+
new_tag: z19.string().describe('New tag name (without #, e.g., "work")'),
|
|
11900
|
+
rename_children: z19.boolean().optional().describe("Also rename child tags (e.g., #project/active \u2192 #work/active). Default: true"),
|
|
11901
|
+
folder: z19.string().optional().describe('Limit to notes in this folder (e.g., "projects")'),
|
|
11902
|
+
dry_run: z19.boolean().optional().describe("Preview only, no changes (default: true)"),
|
|
11903
|
+
commit: z19.boolean().optional().describe("Commit changes to git (default: false)")
|
|
11904
|
+
}
|
|
11905
|
+
},
|
|
11906
|
+
async ({ old_tag, new_tag, rename_children, folder, dry_run, commit }) => {
|
|
11907
|
+
const index = getIndex();
|
|
11908
|
+
const vaultPath2 = getVaultPath();
|
|
11909
|
+
const result = await renameTag(index, vaultPath2, old_tag, new_tag, {
|
|
11910
|
+
rename_children: rename_children ?? true,
|
|
11911
|
+
folder,
|
|
11912
|
+
dry_run: dry_run ?? true,
|
|
11913
|
+
commit: commit ?? false
|
|
11914
|
+
});
|
|
11915
|
+
return {
|
|
11916
|
+
content: [
|
|
11917
|
+
{
|
|
11918
|
+
type: "text",
|
|
11919
|
+
text: JSON.stringify(result, null, 2)
|
|
11920
|
+
}
|
|
11921
|
+
]
|
|
11922
|
+
};
|
|
11923
|
+
}
|
|
11924
|
+
);
|
|
11925
|
+
}
|
|
11926
|
+
|
|
11927
|
+
// src/tools/write/wikilinkFeedback.ts
|
|
11928
|
+
import { z as z20 } from "zod";
|
|
11929
|
+
function registerWikilinkFeedbackTools(server2, getStateDb) {
|
|
11930
|
+
server2.registerTool(
|
|
11931
|
+
"wikilink_feedback",
|
|
11932
|
+
{
|
|
11933
|
+
title: "Wikilink Feedback",
|
|
11934
|
+
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.',
|
|
11935
|
+
inputSchema: {
|
|
11936
|
+
mode: z20.enum(["report", "list", "stats"]).describe("Operation mode"),
|
|
11937
|
+
entity: z20.string().optional().describe("Entity name (required for report mode, optional filter for list/stats)"),
|
|
11938
|
+
note_path: z20.string().optional().describe("Note path where the wikilink appeared (for report mode)"),
|
|
11939
|
+
context: z20.string().optional().describe("Surrounding text context (for report mode)"),
|
|
11940
|
+
correct: z20.boolean().optional().describe("Whether the wikilink was correct (for report mode)"),
|
|
11941
|
+
limit: z20.number().optional().describe("Max entries to return for list mode (default: 20)")
|
|
11942
|
+
}
|
|
11943
|
+
},
|
|
11944
|
+
async ({ mode, entity, note_path, context, correct, limit }) => {
|
|
11945
|
+
const stateDb2 = getStateDb();
|
|
11946
|
+
if (!stateDb2) {
|
|
11947
|
+
return {
|
|
11948
|
+
content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available" }) }]
|
|
11949
|
+
};
|
|
11950
|
+
}
|
|
11951
|
+
let result;
|
|
11952
|
+
switch (mode) {
|
|
11953
|
+
case "report": {
|
|
11954
|
+
if (!entity || correct === void 0) {
|
|
11955
|
+
return {
|
|
11956
|
+
content: [{ type: "text", text: JSON.stringify({ error: "entity and correct are required for report mode" }) }]
|
|
11957
|
+
};
|
|
11958
|
+
}
|
|
11959
|
+
recordFeedback(stateDb2, entity, context || "", note_path || "", correct);
|
|
11960
|
+
const suppressionUpdated = updateSuppressionList(stateDb2) > 0;
|
|
11961
|
+
result = {
|
|
11962
|
+
mode: "report",
|
|
11963
|
+
reported: {
|
|
11964
|
+
entity,
|
|
11965
|
+
correct,
|
|
11966
|
+
suppression_updated: suppressionUpdated
|
|
11967
|
+
},
|
|
11968
|
+
total_suppressed: getSuppressedCount(stateDb2)
|
|
11969
|
+
};
|
|
11970
|
+
break;
|
|
11971
|
+
}
|
|
11972
|
+
case "list": {
|
|
11973
|
+
const entries = getFeedback(stateDb2, entity, limit ?? 20);
|
|
11974
|
+
result = {
|
|
11975
|
+
mode: "list",
|
|
11976
|
+
entries,
|
|
11977
|
+
total_feedback: entries.length
|
|
11978
|
+
};
|
|
11979
|
+
break;
|
|
11980
|
+
}
|
|
11981
|
+
case "stats": {
|
|
11982
|
+
const stats = getEntityStats(stateDb2);
|
|
11983
|
+
result = {
|
|
11984
|
+
mode: "stats",
|
|
11985
|
+
stats,
|
|
11986
|
+
total_feedback: stats.reduce((sum, s) => sum + s.total, 0),
|
|
11987
|
+
total_suppressed: getSuppressedCount(stateDb2)
|
|
11988
|
+
};
|
|
11989
|
+
break;
|
|
11990
|
+
}
|
|
11991
|
+
}
|
|
11992
|
+
return {
|
|
11993
|
+
content: [
|
|
11994
|
+
{
|
|
11995
|
+
type: "text",
|
|
11996
|
+
text: JSON.stringify(result, null, 2)
|
|
11997
|
+
}
|
|
11998
|
+
]
|
|
11999
|
+
};
|
|
12000
|
+
}
|
|
12001
|
+
);
|
|
12002
|
+
}
|
|
12003
|
+
|
|
12004
|
+
// src/tools/read/metrics.ts
|
|
12005
|
+
import { z as z21 } from "zod";
|
|
12006
|
+
|
|
12007
|
+
// src/core/shared/metrics.ts
|
|
12008
|
+
var ALL_METRICS = [
|
|
12009
|
+
"note_count",
|
|
12010
|
+
"link_count",
|
|
12011
|
+
"orphan_count",
|
|
12012
|
+
"tag_count",
|
|
12013
|
+
"entity_count",
|
|
12014
|
+
"avg_links_per_note",
|
|
12015
|
+
"link_density",
|
|
12016
|
+
"connected_ratio"
|
|
12017
|
+
];
|
|
12018
|
+
function computeMetrics(index) {
|
|
12019
|
+
const noteCount = index.notes.size;
|
|
12020
|
+
let linkCount = 0;
|
|
12021
|
+
for (const note of index.notes.values()) {
|
|
12022
|
+
linkCount += note.outlinks.length;
|
|
12023
|
+
}
|
|
12024
|
+
const connectedNotes = /* @__PURE__ */ new Set();
|
|
12025
|
+
for (const [notePath, note] of index.notes) {
|
|
12026
|
+
if (note.outlinks.length > 0) {
|
|
12027
|
+
connectedNotes.add(notePath);
|
|
12028
|
+
}
|
|
12029
|
+
}
|
|
12030
|
+
for (const [target, backlinks] of index.backlinks) {
|
|
12031
|
+
for (const bl of backlinks) {
|
|
12032
|
+
connectedNotes.add(bl.source);
|
|
12033
|
+
}
|
|
12034
|
+
for (const note of index.notes.values()) {
|
|
12035
|
+
const normalizedTitle = note.title.toLowerCase();
|
|
12036
|
+
if (normalizedTitle === target.toLowerCase() || note.path.toLowerCase() === target.toLowerCase()) {
|
|
12037
|
+
connectedNotes.add(note.path);
|
|
12038
|
+
}
|
|
12039
|
+
}
|
|
12040
|
+
}
|
|
12041
|
+
let orphanCount = 0;
|
|
12042
|
+
for (const [notePath, note] of index.notes) {
|
|
12043
|
+
if (!connectedNotes.has(notePath)) {
|
|
12044
|
+
orphanCount++;
|
|
12045
|
+
}
|
|
12046
|
+
}
|
|
12047
|
+
const tagCount = index.tags.size;
|
|
12048
|
+
const entityCount = index.entities.size;
|
|
12049
|
+
const avgLinksPerNote = noteCount > 0 ? linkCount / noteCount : 0;
|
|
12050
|
+
const possibleLinks = noteCount * (noteCount - 1);
|
|
12051
|
+
const linkDensity = possibleLinks > 0 ? linkCount / possibleLinks : 0;
|
|
12052
|
+
const connectedRatio = noteCount > 0 ? connectedNotes.size / noteCount : 0;
|
|
12053
|
+
return {
|
|
12054
|
+
note_count: noteCount,
|
|
12055
|
+
link_count: linkCount,
|
|
12056
|
+
orphan_count: orphanCount,
|
|
12057
|
+
tag_count: tagCount,
|
|
12058
|
+
entity_count: entityCount,
|
|
12059
|
+
avg_links_per_note: Math.round(avgLinksPerNote * 100) / 100,
|
|
12060
|
+
link_density: Math.round(linkDensity * 1e4) / 1e4,
|
|
12061
|
+
connected_ratio: Math.round(connectedRatio * 1e3) / 1e3
|
|
12062
|
+
};
|
|
12063
|
+
}
|
|
12064
|
+
function recordMetrics(stateDb2, metrics) {
|
|
12065
|
+
const timestamp = Date.now();
|
|
12066
|
+
const insert = stateDb2.db.prepare(
|
|
12067
|
+
"INSERT INTO vault_metrics (timestamp, metric, value) VALUES (?, ?, ?)"
|
|
12068
|
+
);
|
|
12069
|
+
const transaction = stateDb2.db.transaction(() => {
|
|
12070
|
+
for (const [metric, value] of Object.entries(metrics)) {
|
|
12071
|
+
insert.run(timestamp, metric, value);
|
|
12072
|
+
}
|
|
12073
|
+
});
|
|
12074
|
+
transaction();
|
|
12075
|
+
}
|
|
12076
|
+
function getMetricHistory(stateDb2, metric, daysBack = 30) {
|
|
12077
|
+
const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1e3;
|
|
12078
|
+
let rows;
|
|
12079
|
+
if (metric) {
|
|
12080
|
+
rows = stateDb2.db.prepare(
|
|
12081
|
+
"SELECT timestamp, metric, value FROM vault_metrics WHERE metric = ? AND timestamp >= ? ORDER BY timestamp"
|
|
12082
|
+
).all(metric, cutoff);
|
|
12083
|
+
} else {
|
|
12084
|
+
rows = stateDb2.db.prepare(
|
|
12085
|
+
"SELECT timestamp, metric, value FROM vault_metrics WHERE timestamp >= ? ORDER BY timestamp"
|
|
12086
|
+
).all(cutoff);
|
|
12087
|
+
}
|
|
12088
|
+
return rows.map((r) => ({
|
|
12089
|
+
metric: r.metric,
|
|
12090
|
+
value: r.value,
|
|
12091
|
+
timestamp: r.timestamp
|
|
12092
|
+
}));
|
|
12093
|
+
}
|
|
12094
|
+
function computeTrends(stateDb2, currentMetrics, daysBack = 30) {
|
|
12095
|
+
const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1e3;
|
|
12096
|
+
const rows = stateDb2.db.prepare(`
|
|
12097
|
+
SELECT metric, value FROM vault_metrics
|
|
12098
|
+
WHERE timestamp >= ? AND timestamp <= ?
|
|
12099
|
+
GROUP BY metric
|
|
12100
|
+
HAVING timestamp = MIN(timestamp)
|
|
12101
|
+
`).all(cutoff, cutoff + 24 * 60 * 60 * 1e3);
|
|
12102
|
+
const previousValues = /* @__PURE__ */ new Map();
|
|
12103
|
+
for (const row of rows) {
|
|
12104
|
+
previousValues.set(row.metric, row.value);
|
|
12105
|
+
}
|
|
12106
|
+
if (previousValues.size === 0) {
|
|
12107
|
+
const fallbackRows = stateDb2.db.prepare(`
|
|
12108
|
+
SELECT metric, MIN(value) as value FROM vault_metrics
|
|
12109
|
+
WHERE timestamp >= ?
|
|
12110
|
+
GROUP BY metric
|
|
12111
|
+
HAVING timestamp = MIN(timestamp)
|
|
12112
|
+
`).all(cutoff);
|
|
12113
|
+
for (const row of fallbackRows) {
|
|
12114
|
+
previousValues.set(row.metric, row.value);
|
|
12115
|
+
}
|
|
12116
|
+
}
|
|
12117
|
+
const trends = [];
|
|
12118
|
+
for (const metricName of ALL_METRICS) {
|
|
12119
|
+
const current = currentMetrics[metricName] ?? 0;
|
|
12120
|
+
const previous = previousValues.get(metricName) ?? current;
|
|
12121
|
+
const delta = current - previous;
|
|
12122
|
+
const deltaPct = previous !== 0 ? Math.round(delta / previous * 1e4) / 100 : delta !== 0 ? 100 : 0;
|
|
12123
|
+
let direction = "stable";
|
|
12124
|
+
if (delta > 0) direction = "up";
|
|
12125
|
+
if (delta < 0) direction = "down";
|
|
12126
|
+
trends.push({
|
|
12127
|
+
metric: metricName,
|
|
12128
|
+
current,
|
|
12129
|
+
previous,
|
|
12130
|
+
delta,
|
|
12131
|
+
delta_percent: deltaPct,
|
|
12132
|
+
direction
|
|
12133
|
+
});
|
|
12134
|
+
}
|
|
12135
|
+
return trends;
|
|
12136
|
+
}
|
|
12137
|
+
function purgeOldMetrics(stateDb2, retentionDays = 90) {
|
|
12138
|
+
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1e3;
|
|
12139
|
+
const result = stateDb2.db.prepare(
|
|
12140
|
+
"DELETE FROM vault_metrics WHERE timestamp < ?"
|
|
12141
|
+
).run(cutoff);
|
|
12142
|
+
return result.changes;
|
|
12143
|
+
}
|
|
12144
|
+
|
|
12145
|
+
// src/tools/read/metrics.ts
|
|
12146
|
+
function registerMetricsTools(server2, getIndex, getStateDb) {
|
|
12147
|
+
server2.registerTool(
|
|
12148
|
+
"vault_growth",
|
|
12149
|
+
{
|
|
12150
|
+
title: "Vault Growth",
|
|
12151
|
+
description: 'Track vault growth over time. Modes: "current" (live snapshot), "history" (time series), "trends" (deltas vs N days ago). Tracks 8 metrics: note_count, link_count, orphan_count, tag_count, entity_count, avg_links_per_note, link_density, connected_ratio.',
|
|
12152
|
+
inputSchema: {
|
|
12153
|
+
mode: z21.enum(["current", "history", "trends"]).describe("Query mode: current snapshot, historical time series, or trend analysis"),
|
|
12154
|
+
metric: z21.string().optional().describe('Filter to specific metric (e.g., "note_count"). Omit for all metrics.'),
|
|
12155
|
+
days_back: z21.number().optional().describe("Number of days to look back for history/trends (default: 30)")
|
|
12156
|
+
}
|
|
12157
|
+
},
|
|
12158
|
+
async ({ mode, metric, days_back }) => {
|
|
12159
|
+
const index = getIndex();
|
|
12160
|
+
const stateDb2 = getStateDb();
|
|
12161
|
+
const daysBack = days_back ?? 30;
|
|
12162
|
+
let result;
|
|
12163
|
+
switch (mode) {
|
|
12164
|
+
case "current": {
|
|
12165
|
+
const metrics = computeMetrics(index);
|
|
12166
|
+
result = {
|
|
12167
|
+
mode: "current",
|
|
12168
|
+
metrics,
|
|
12169
|
+
recorded_at: Date.now()
|
|
12170
|
+
};
|
|
12171
|
+
break;
|
|
12172
|
+
}
|
|
12173
|
+
case "history": {
|
|
12174
|
+
if (!stateDb2) {
|
|
12175
|
+
return {
|
|
12176
|
+
content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available for historical queries" }) }]
|
|
12177
|
+
};
|
|
12178
|
+
}
|
|
12179
|
+
const history = getMetricHistory(stateDb2, metric, daysBack);
|
|
12180
|
+
result = {
|
|
12181
|
+
mode: "history",
|
|
12182
|
+
history
|
|
12183
|
+
};
|
|
12184
|
+
break;
|
|
12185
|
+
}
|
|
12186
|
+
case "trends": {
|
|
12187
|
+
if (!stateDb2) {
|
|
12188
|
+
return {
|
|
12189
|
+
content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available for trend analysis" }) }]
|
|
12190
|
+
};
|
|
12191
|
+
}
|
|
12192
|
+
const currentMetrics = computeMetrics(index);
|
|
12193
|
+
const trends = computeTrends(stateDb2, currentMetrics, daysBack);
|
|
12194
|
+
result = {
|
|
12195
|
+
mode: "trends",
|
|
12196
|
+
trends
|
|
12197
|
+
};
|
|
12198
|
+
break;
|
|
12199
|
+
}
|
|
12200
|
+
}
|
|
12201
|
+
return {
|
|
12202
|
+
content: [
|
|
12203
|
+
{
|
|
12204
|
+
type: "text",
|
|
12205
|
+
text: JSON.stringify(result, null, 2)
|
|
12206
|
+
}
|
|
12207
|
+
]
|
|
12208
|
+
};
|
|
12209
|
+
}
|
|
12210
|
+
);
|
|
12211
|
+
}
|
|
12212
|
+
|
|
11479
12213
|
// src/resources/vault.ts
|
|
11480
12214
|
function registerVaultResources(server2, getIndex) {
|
|
11481
12215
|
server2.registerResource(
|
|
@@ -11708,9 +12442,14 @@ var TOOL_CATEGORY = {
|
|
|
11708
12442
|
vault_undo_last_mutation: "git",
|
|
11709
12443
|
// policy
|
|
11710
12444
|
policy: "policy",
|
|
11711
|
-
// schema (migrations)
|
|
12445
|
+
// schema (migrations + tag rename)
|
|
11712
12446
|
rename_field: "schema",
|
|
11713
|
-
migrate_field_values: "schema"
|
|
12447
|
+
migrate_field_values: "schema",
|
|
12448
|
+
rename_tag: "schema",
|
|
12449
|
+
// health (growth metrics)
|
|
12450
|
+
vault_growth: "health",
|
|
12451
|
+
// wikilinks (feedback)
|
|
12452
|
+
wikilink_feedback: "wikilinks"
|
|
11714
12453
|
};
|
|
11715
12454
|
var server = new McpServer({
|
|
11716
12455
|
name: "flywheel-memory",
|
|
@@ -11769,6 +12508,9 @@ registerNoteTools(server, vaultPath, () => vaultIndex);
|
|
|
11769
12508
|
registerMoveNoteTools(server, vaultPath);
|
|
11770
12509
|
registerSystemTools2(server, vaultPath);
|
|
11771
12510
|
registerPolicyTools(server, vaultPath);
|
|
12511
|
+
registerTagTools(server, () => vaultIndex, () => vaultPath);
|
|
12512
|
+
registerWikilinkFeedbackTools(server, () => stateDb);
|
|
12513
|
+
registerMetricsTools(server, () => vaultIndex, () => stateDb);
|
|
11772
12514
|
registerVaultResources(server, () => vaultIndex ?? null);
|
|
11773
12515
|
console.error(`[Memory] Registered ${_registeredCount} tools, skipped ${_skippedCount}`);
|
|
11774
12516
|
async function main() {
|
|
@@ -11874,6 +12616,23 @@ async function updateEntitiesInStateDb() {
|
|
|
11874
12616
|
async function runPostIndexWork(index) {
|
|
11875
12617
|
await updateEntitiesInStateDb();
|
|
11876
12618
|
await exportHubScores(index, stateDb);
|
|
12619
|
+
if (stateDb) {
|
|
12620
|
+
try {
|
|
12621
|
+
const metrics = computeMetrics(index);
|
|
12622
|
+
recordMetrics(stateDb, metrics);
|
|
12623
|
+
purgeOldMetrics(stateDb, 90);
|
|
12624
|
+
console.error("[Memory] Growth metrics recorded");
|
|
12625
|
+
} catch (err) {
|
|
12626
|
+
console.error("[Memory] Failed to record metrics:", err);
|
|
12627
|
+
}
|
|
12628
|
+
}
|
|
12629
|
+
if (stateDb) {
|
|
12630
|
+
try {
|
|
12631
|
+
updateSuppressionList(stateDb);
|
|
12632
|
+
} catch (err) {
|
|
12633
|
+
console.error("[Memory] Failed to update suppression list:", err);
|
|
12634
|
+
}
|
|
12635
|
+
}
|
|
11877
12636
|
const existing = loadConfig(stateDb);
|
|
11878
12637
|
const inferred = inferConfig(index, vaultPath);
|
|
11879
12638
|
if (stateDb) {
|
|
@@ -11939,8 +12698,8 @@ async function runPostIndexWork(index) {
|
|
|
11939
12698
|
}
|
|
11940
12699
|
});
|
|
11941
12700
|
let rebuildTimer;
|
|
11942
|
-
legacyWatcher.on("all", (event,
|
|
11943
|
-
if (!
|
|
12701
|
+
legacyWatcher.on("all", (event, path25) => {
|
|
12702
|
+
if (!path25.endsWith(".md")) return;
|
|
11944
12703
|
clearTimeout(rebuildTimer);
|
|
11945
12704
|
rebuildTimer = setTimeout(() => {
|
|
11946
12705
|
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.11",
|
|
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.11",
|
|
54
54
|
"better-sqlite3": "^11.0.0",
|
|
55
55
|
"chokidar": "^4.0.0",
|
|
56
56
|
"gray-matter": "^4.0.3",
|