@velvetmonkey/flywheel-memory 2.0.10 → 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 +718 -63
- 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
|
|
@@ -5553,14 +5658,14 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
|
|
|
5553
5658
|
};
|
|
5554
5659
|
function findSimilarEntity2(target, entities) {
|
|
5555
5660
|
const targetLower = target.toLowerCase();
|
|
5556
|
-
for (const [name,
|
|
5661
|
+
for (const [name, path25] of entities) {
|
|
5557
5662
|
if (name.startsWith(targetLower) || targetLower.startsWith(name)) {
|
|
5558
|
-
return
|
|
5663
|
+
return path25;
|
|
5559
5664
|
}
|
|
5560
5665
|
}
|
|
5561
|
-
for (const [name,
|
|
5666
|
+
for (const [name, path25] of entities) {
|
|
5562
5667
|
if (name.includes(targetLower) || targetLower.includes(name)) {
|
|
5563
|
-
return
|
|
5668
|
+
return path25;
|
|
5564
5669
|
}
|
|
5565
5670
|
}
|
|
5566
5671
|
return void 0;
|
|
@@ -6037,8 +6142,8 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
|
|
|
6037
6142
|
daily_counts: z3.record(z3.number())
|
|
6038
6143
|
}).describe("Activity summary for the last 7 days")
|
|
6039
6144
|
};
|
|
6040
|
-
function isPeriodicNote(
|
|
6041
|
-
const filename =
|
|
6145
|
+
function isPeriodicNote(path25) {
|
|
6146
|
+
const filename = path25.split("/").pop() || "";
|
|
6042
6147
|
const nameWithoutExt = filename.replace(/\.md$/, "");
|
|
6043
6148
|
const patterns = [
|
|
6044
6149
|
/^\d{4}-\d{2}-\d{2}$/,
|
|
@@ -6053,7 +6158,7 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
|
|
|
6053
6158
|
// YYYY (yearly)
|
|
6054
6159
|
];
|
|
6055
6160
|
const periodicFolders = ["daily", "weekly", "monthly", "quarterly", "yearly", "journal", "journals"];
|
|
6056
|
-
const folder =
|
|
6161
|
+
const folder = path25.split("/")[0]?.toLowerCase() || "";
|
|
6057
6162
|
return patterns.some((p) => p.test(nameWithoutExt)) || periodicFolders.includes(folder);
|
|
6058
6163
|
}
|
|
6059
6164
|
server2.registerTool(
|
|
@@ -7058,18 +7163,18 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
|
|
|
7058
7163
|
include_content: z6.boolean().default(false).describe("Include the text content under each top-level section")
|
|
7059
7164
|
}
|
|
7060
7165
|
},
|
|
7061
|
-
async ({ path:
|
|
7166
|
+
async ({ path: path25, include_content }) => {
|
|
7062
7167
|
const index = getIndex();
|
|
7063
7168
|
const vaultPath2 = getVaultPath();
|
|
7064
|
-
const result = await getNoteStructure(index,
|
|
7169
|
+
const result = await getNoteStructure(index, path25, vaultPath2);
|
|
7065
7170
|
if (!result) {
|
|
7066
7171
|
return {
|
|
7067
|
-
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) }]
|
|
7068
7173
|
};
|
|
7069
7174
|
}
|
|
7070
7175
|
if (include_content) {
|
|
7071
7176
|
for (const section of result.sections) {
|
|
7072
|
-
const sectionResult = await getSectionContent(index,
|
|
7177
|
+
const sectionResult = await getSectionContent(index, path25, section.heading.text, vaultPath2, true);
|
|
7073
7178
|
if (sectionResult) {
|
|
7074
7179
|
section.content = sectionResult.content;
|
|
7075
7180
|
}
|
|
@@ -7091,15 +7196,15 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
|
|
|
7091
7196
|
include_subheadings: z6.boolean().default(true).describe("Include content under subheadings")
|
|
7092
7197
|
}
|
|
7093
7198
|
},
|
|
7094
|
-
async ({ path:
|
|
7199
|
+
async ({ path: path25, heading, include_subheadings }) => {
|
|
7095
7200
|
const index = getIndex();
|
|
7096
7201
|
const vaultPath2 = getVaultPath();
|
|
7097
|
-
const result = await getSectionContent(index,
|
|
7202
|
+
const result = await getSectionContent(index, path25, heading, vaultPath2, include_subheadings);
|
|
7098
7203
|
if (!result) {
|
|
7099
7204
|
return {
|
|
7100
7205
|
content: [{ type: "text", text: JSON.stringify({
|
|
7101
7206
|
error: "Section not found",
|
|
7102
|
-
path:
|
|
7207
|
+
path: path25,
|
|
7103
7208
|
heading
|
|
7104
7209
|
}, null, 2) }]
|
|
7105
7210
|
};
|
|
@@ -7153,16 +7258,16 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
|
|
|
7153
7258
|
offset: z6.coerce.number().default(0).describe("Number of results to skip (for pagination)")
|
|
7154
7259
|
}
|
|
7155
7260
|
},
|
|
7156
|
-
async ({ path:
|
|
7261
|
+
async ({ path: path25, status, has_due_date, folder, tag, limit: requestedLimit, offset }) => {
|
|
7157
7262
|
const limit = Math.min(requestedLimit ?? 25, MAX_LIMIT);
|
|
7158
7263
|
const index = getIndex();
|
|
7159
7264
|
const vaultPath2 = getVaultPath();
|
|
7160
7265
|
const config = getConfig();
|
|
7161
|
-
if (
|
|
7162
|
-
const result2 = await getTasksFromNote(index,
|
|
7266
|
+
if (path25) {
|
|
7267
|
+
const result2 = await getTasksFromNote(index, path25, vaultPath2, config.exclude_task_tags || []);
|
|
7163
7268
|
if (!result2) {
|
|
7164
7269
|
return {
|
|
7165
|
-
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) }]
|
|
7166
7271
|
};
|
|
7167
7272
|
}
|
|
7168
7273
|
let filtered = result2;
|
|
@@ -7172,7 +7277,7 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
|
|
|
7172
7277
|
const paged2 = filtered.slice(offset, offset + limit);
|
|
7173
7278
|
return {
|
|
7174
7279
|
content: [{ type: "text", text: JSON.stringify({
|
|
7175
|
-
path:
|
|
7280
|
+
path: path25,
|
|
7176
7281
|
total_count: filtered.length,
|
|
7177
7282
|
returned_count: paged2.length,
|
|
7178
7283
|
open: result2.filter((t) => t.status === "open").length,
|
|
@@ -9614,8 +9719,8 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
|
|
|
9614
9719
|
const templatePath = path18.join(vaultPath2, template);
|
|
9615
9720
|
try {
|
|
9616
9721
|
const raw = await fs18.readFile(templatePath, "utf-8");
|
|
9617
|
-
const
|
|
9618
|
-
const parsed =
|
|
9722
|
+
const matter9 = (await import("gray-matter")).default;
|
|
9723
|
+
const parsed = matter9(raw);
|
|
9619
9724
|
const dateStr = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
9620
9725
|
const title = path18.basename(notePath, ".md");
|
|
9621
9726
|
let templateContent = parsed.content.replace(/\{\{date\}\}/g, dateStr).replace(/\{\{title\}\}/g, title);
|
|
@@ -11580,6 +11685,531 @@ function registerPolicyTools(server2, vaultPath2) {
|
|
|
11580
11685
|
);
|
|
11581
11686
|
}
|
|
11582
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
|
+
|
|
11583
12213
|
// src/resources/vault.ts
|
|
11584
12214
|
function registerVaultResources(server2, getIndex) {
|
|
11585
12215
|
server2.registerResource(
|
|
@@ -11812,9 +12442,14 @@ var TOOL_CATEGORY = {
|
|
|
11812
12442
|
vault_undo_last_mutation: "git",
|
|
11813
12443
|
// policy
|
|
11814
12444
|
policy: "policy",
|
|
11815
|
-
// schema (migrations)
|
|
12445
|
+
// schema (migrations + tag rename)
|
|
11816
12446
|
rename_field: "schema",
|
|
11817
|
-
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"
|
|
11818
12453
|
};
|
|
11819
12454
|
var server = new McpServer({
|
|
11820
12455
|
name: "flywheel-memory",
|
|
@@ -11873,6 +12508,9 @@ registerNoteTools(server, vaultPath, () => vaultIndex);
|
|
|
11873
12508
|
registerMoveNoteTools(server, vaultPath);
|
|
11874
12509
|
registerSystemTools2(server, vaultPath);
|
|
11875
12510
|
registerPolicyTools(server, vaultPath);
|
|
12511
|
+
registerTagTools(server, () => vaultIndex, () => vaultPath);
|
|
12512
|
+
registerWikilinkFeedbackTools(server, () => stateDb);
|
|
12513
|
+
registerMetricsTools(server, () => vaultIndex, () => stateDb);
|
|
11876
12514
|
registerVaultResources(server, () => vaultIndex ?? null);
|
|
11877
12515
|
console.error(`[Memory] Registered ${_registeredCount} tools, skipped ${_skippedCount}`);
|
|
11878
12516
|
async function main() {
|
|
@@ -11978,6 +12616,23 @@ async function updateEntitiesInStateDb() {
|
|
|
11978
12616
|
async function runPostIndexWork(index) {
|
|
11979
12617
|
await updateEntitiesInStateDb();
|
|
11980
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
|
+
}
|
|
11981
12636
|
const existing = loadConfig(stateDb);
|
|
11982
12637
|
const inferred = inferConfig(index, vaultPath);
|
|
11983
12638
|
if (stateDb) {
|
|
@@ -12043,8 +12698,8 @@ async function runPostIndexWork(index) {
|
|
|
12043
12698
|
}
|
|
12044
12699
|
});
|
|
12045
12700
|
let rebuildTimer;
|
|
12046
|
-
legacyWatcher.on("all", (event,
|
|
12047
|
-
if (!
|
|
12701
|
+
legacyWatcher.on("all", (event, path25) => {
|
|
12702
|
+
if (!path25.endsWith(".md")) return;
|
|
12048
12703
|
clearTimeout(rebuildTimer);
|
|
12049
12704
|
rebuildTimer = setTimeout(() => {
|
|
12050
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",
|