@velvetmonkey/flywheel-memory 2.0.33 → 2.0.34
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 +373 -7
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -2487,7 +2487,10 @@ var DEFAULT_CONFIG = {
|
|
|
2487
2487
|
exclude_task_tags: [],
|
|
2488
2488
|
exclude_analysis_tags: [],
|
|
2489
2489
|
exclude_entities: [],
|
|
2490
|
-
exclude_entity_folders: []
|
|
2490
|
+
exclude_entity_folders: [],
|
|
2491
|
+
wikilink_strictness: "balanced",
|
|
2492
|
+
implicit_detection: true,
|
|
2493
|
+
adaptive_strictness: true
|
|
2491
2494
|
};
|
|
2492
2495
|
function loadConfig(stateDb2) {
|
|
2493
2496
|
if (stateDb2) {
|
|
@@ -4902,6 +4905,24 @@ function setWriteStateDb(stateDb2) {
|
|
|
4902
4905
|
function getWriteStateDb() {
|
|
4903
4906
|
return moduleStateDb4;
|
|
4904
4907
|
}
|
|
4908
|
+
var moduleConfig = null;
|
|
4909
|
+
var ALL_IMPLICIT_PATTERNS = ["proper-nouns", "single-caps", "quoted-terms", "camel-case", "acronyms"];
|
|
4910
|
+
function setWikilinkConfig(config) {
|
|
4911
|
+
moduleConfig = config;
|
|
4912
|
+
}
|
|
4913
|
+
function getWikilinkStrictness() {
|
|
4914
|
+
return moduleConfig?.wikilink_strictness ?? "balanced";
|
|
4915
|
+
}
|
|
4916
|
+
function getEffectiveStrictness(notePath) {
|
|
4917
|
+
const base = getWikilinkStrictness();
|
|
4918
|
+
if (moduleConfig?.adaptive_strictness === false) return base;
|
|
4919
|
+
const context = notePath ? getNoteContext(notePath) : "general";
|
|
4920
|
+
if (context === "daily") return "aggressive";
|
|
4921
|
+
return base;
|
|
4922
|
+
}
|
|
4923
|
+
function getCooccurrenceIndex() {
|
|
4924
|
+
return cooccurrenceIndex;
|
|
4925
|
+
}
|
|
4905
4926
|
var entityIndex = null;
|
|
4906
4927
|
var indexReady = false;
|
|
4907
4928
|
var indexError2 = null;
|
|
@@ -5062,9 +5083,11 @@ function processWikilinks(content, notePath) {
|
|
|
5062
5083
|
firstOccurrenceOnly: true,
|
|
5063
5084
|
caseInsensitive: true
|
|
5064
5085
|
});
|
|
5086
|
+
const implicitEnabled = moduleConfig?.implicit_detection !== false;
|
|
5087
|
+
const implicitPatterns = moduleConfig?.implicit_patterns?.length ? moduleConfig.implicit_patterns : [...ALL_IMPLICIT_PATTERNS];
|
|
5065
5088
|
const implicitMatches = detectImplicitEntities(result.content, {
|
|
5066
|
-
detectImplicit:
|
|
5067
|
-
implicitPatterns
|
|
5089
|
+
detectImplicit: implicitEnabled,
|
|
5090
|
+
implicitPatterns,
|
|
5068
5091
|
minEntityLength: 3
|
|
5069
5092
|
});
|
|
5070
5093
|
const alreadyLinked = new Set(
|
|
@@ -5294,7 +5317,6 @@ var STRICTNESS_CONFIGS = {
|
|
|
5294
5317
|
// Standard bonus for exact matches
|
|
5295
5318
|
}
|
|
5296
5319
|
};
|
|
5297
|
-
var DEFAULT_STRICTNESS = "conservative";
|
|
5298
5320
|
var TYPE_BOOST = {
|
|
5299
5321
|
people: 5,
|
|
5300
5322
|
// Names are high value for connections
|
|
@@ -5481,7 +5503,7 @@ async function suggestRelatedLinks(content, options = {}) {
|
|
|
5481
5503
|
const {
|
|
5482
5504
|
maxSuggestions = 3,
|
|
5483
5505
|
excludeLinked = true,
|
|
5484
|
-
strictness =
|
|
5506
|
+
strictness = getEffectiveStrictness(options.notePath),
|
|
5485
5507
|
notePath,
|
|
5486
5508
|
detail = false
|
|
5487
5509
|
} = options;
|
|
@@ -6046,6 +6068,17 @@ function searchFTS5(_vaultPath, query, limit = 10) {
|
|
|
6046
6068
|
function getFTS5State() {
|
|
6047
6069
|
return { ...state };
|
|
6048
6070
|
}
|
|
6071
|
+
function countFTS5Mentions(term) {
|
|
6072
|
+
if (!db2) return 0;
|
|
6073
|
+
try {
|
|
6074
|
+
const result = db2.prepare(
|
|
6075
|
+
"SELECT COUNT(*) as cnt FROM notes_fts WHERE content MATCH ?"
|
|
6076
|
+
).get(`"${term}"`);
|
|
6077
|
+
return result?.cnt ?? 0;
|
|
6078
|
+
} catch {
|
|
6079
|
+
return 0;
|
|
6080
|
+
}
|
|
6081
|
+
}
|
|
6049
6082
|
|
|
6050
6083
|
// src/core/read/taskCache.ts
|
|
6051
6084
|
import * as path10 from "path";
|
|
@@ -7057,12 +7090,13 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
|
|
|
7057
7090
|
inputSchema: {
|
|
7058
7091
|
path: z2.string().optional().describe("Path to a specific note to validate. If omitted, validates all notes."),
|
|
7059
7092
|
typos_only: z2.boolean().default(false).describe("If true, only report broken links that have a similar existing note (likely typos)"),
|
|
7093
|
+
group_by_target: z2.boolean().default(false).describe("If true, aggregate dead links by target and rank by mention frequency. Returns targets[] instead of broken[]."),
|
|
7060
7094
|
limit: z2.coerce.number().default(50).describe("Maximum number of broken links to return"),
|
|
7061
7095
|
offset: z2.coerce.number().default(0).describe("Number of broken links to skip (for pagination)")
|
|
7062
7096
|
},
|
|
7063
7097
|
outputSchema: ValidateLinksOutputSchema
|
|
7064
7098
|
},
|
|
7065
|
-
async ({ path: notePath, typos_only, limit: requestedLimit, offset }) => {
|
|
7099
|
+
async ({ path: notePath, typos_only, group_by_target, limit: requestedLimit, offset }) => {
|
|
7066
7100
|
const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
|
|
7067
7101
|
const index = getIndex();
|
|
7068
7102
|
const allBroken = [];
|
|
@@ -7103,6 +7137,41 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
|
|
|
7103
7137
|
}
|
|
7104
7138
|
}
|
|
7105
7139
|
}
|
|
7140
|
+
if (group_by_target) {
|
|
7141
|
+
const targetMap = /* @__PURE__ */ new Map();
|
|
7142
|
+
for (const broken2 of allBroken) {
|
|
7143
|
+
const key = broken2.target.toLowerCase();
|
|
7144
|
+
const existing = targetMap.get(key);
|
|
7145
|
+
if (existing) {
|
|
7146
|
+
existing.count++;
|
|
7147
|
+
if (existing.sources.size < 5) existing.sources.add(broken2.source);
|
|
7148
|
+
if (!existing.suggestion && broken2.suggestion) existing.suggestion = broken2.suggestion;
|
|
7149
|
+
} else {
|
|
7150
|
+
targetMap.set(key, {
|
|
7151
|
+
count: 1,
|
|
7152
|
+
sources: /* @__PURE__ */ new Set([broken2.source]),
|
|
7153
|
+
suggestion: broken2.suggestion
|
|
7154
|
+
});
|
|
7155
|
+
}
|
|
7156
|
+
}
|
|
7157
|
+
const targets = Array.from(targetMap.entries()).map(([target, data]) => ({
|
|
7158
|
+
target,
|
|
7159
|
+
mention_count: data.count,
|
|
7160
|
+
sources: Array.from(data.sources),
|
|
7161
|
+
...data.suggestion ? { suggestion: data.suggestion } : {}
|
|
7162
|
+
})).sort((a, b) => b.mention_count - a.mention_count).slice(offset, offset + limit);
|
|
7163
|
+
const grouped = {
|
|
7164
|
+
scope: notePath || "all",
|
|
7165
|
+
total_dead_targets: targetMap.size,
|
|
7166
|
+
total_broken_links: allBroken.length,
|
|
7167
|
+
returned_count: targets.length,
|
|
7168
|
+
targets
|
|
7169
|
+
};
|
|
7170
|
+
return {
|
|
7171
|
+
content: [{ type: "text", text: JSON.stringify(grouped, null, 2) }],
|
|
7172
|
+
structuredContent: grouped
|
|
7173
|
+
};
|
|
7174
|
+
}
|
|
7106
7175
|
const broken = allBroken.slice(offset, offset + limit);
|
|
7107
7176
|
const output = {
|
|
7108
7177
|
scope: notePath || "all",
|
|
@@ -7123,6 +7192,106 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
|
|
|
7123
7192
|
};
|
|
7124
7193
|
}
|
|
7125
7194
|
);
|
|
7195
|
+
server2.registerTool(
|
|
7196
|
+
"discover_stub_candidates",
|
|
7197
|
+
{
|
|
7198
|
+
title: "Discover Stub Candidates",
|
|
7199
|
+
description: `Find terms referenced via dead wikilinks across the vault that have no backing note. These are "invisible concepts" \u2014 topics your vault considers important enough to link to but that don't have their own notes yet. Ranked by reference frequency.`,
|
|
7200
|
+
inputSchema: {
|
|
7201
|
+
min_frequency: z2.coerce.number().default(2).describe("Minimum number of references to include (default 2)"),
|
|
7202
|
+
limit: z2.coerce.number().default(20).describe("Maximum candidates to return (default 20)")
|
|
7203
|
+
}
|
|
7204
|
+
},
|
|
7205
|
+
async ({ min_frequency, limit: requestedLimit }) => {
|
|
7206
|
+
const index = getIndex();
|
|
7207
|
+
const limit = Math.min(requestedLimit ?? 20, 100);
|
|
7208
|
+
const minFreq = min_frequency ?? 2;
|
|
7209
|
+
const targetMap = /* @__PURE__ */ new Map();
|
|
7210
|
+
for (const note of index.notes.values()) {
|
|
7211
|
+
for (const link of note.outlinks) {
|
|
7212
|
+
if (!resolveTarget(index, link.target)) {
|
|
7213
|
+
const key = link.target.toLowerCase();
|
|
7214
|
+
const existing = targetMap.get(key);
|
|
7215
|
+
if (existing) {
|
|
7216
|
+
existing.count++;
|
|
7217
|
+
if (existing.sources.size < 3) existing.sources.add(note.path);
|
|
7218
|
+
} else {
|
|
7219
|
+
targetMap.set(key, { count: 1, sources: /* @__PURE__ */ new Set([note.path]) });
|
|
7220
|
+
}
|
|
7221
|
+
}
|
|
7222
|
+
}
|
|
7223
|
+
}
|
|
7224
|
+
const candidates = Array.from(targetMap.entries()).filter(([, data]) => data.count >= minFreq).map(([target, data]) => {
|
|
7225
|
+
const fts5Mentions = countFTS5Mentions(target);
|
|
7226
|
+
return {
|
|
7227
|
+
term: target,
|
|
7228
|
+
wikilink_references: data.count,
|
|
7229
|
+
content_mentions: fts5Mentions,
|
|
7230
|
+
sample_notes: Array.from(data.sources)
|
|
7231
|
+
};
|
|
7232
|
+
}).sort((a, b) => b.wikilink_references - a.wikilink_references).slice(0, limit);
|
|
7233
|
+
const output = {
|
|
7234
|
+
total_dead_targets: targetMap.size,
|
|
7235
|
+
candidates_above_threshold: candidates.length,
|
|
7236
|
+
candidates
|
|
7237
|
+
};
|
|
7238
|
+
return {
|
|
7239
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }]
|
|
7240
|
+
};
|
|
7241
|
+
}
|
|
7242
|
+
);
|
|
7243
|
+
server2.registerTool(
|
|
7244
|
+
"discover_cooccurrence_gaps",
|
|
7245
|
+
{
|
|
7246
|
+
title: "Discover Co-occurrence Gaps",
|
|
7247
|
+
description: "Find entity pairs that frequently co-occur across vault notes but where one or both entities lack a backing note. These represent relationship patterns worth making explicit with hub notes or links.",
|
|
7248
|
+
inputSchema: {
|
|
7249
|
+
min_cooccurrence: z2.coerce.number().default(3).describe("Minimum co-occurrence count to include (default 3)"),
|
|
7250
|
+
limit: z2.coerce.number().default(20).describe("Maximum gaps to return (default 20)")
|
|
7251
|
+
}
|
|
7252
|
+
},
|
|
7253
|
+
async ({ min_cooccurrence, limit: requestedLimit }) => {
|
|
7254
|
+
const index = getIndex();
|
|
7255
|
+
const coocIndex = getCooccurrenceIndex();
|
|
7256
|
+
const limit = Math.min(requestedLimit ?? 20, 100);
|
|
7257
|
+
const minCount = min_cooccurrence ?? 3;
|
|
7258
|
+
if (!coocIndex) {
|
|
7259
|
+
return {
|
|
7260
|
+
content: [{ type: "text", text: JSON.stringify({ error: "Co-occurrence index not built yet. Wait for entity index initialization." }) }]
|
|
7261
|
+
};
|
|
7262
|
+
}
|
|
7263
|
+
const gaps = [];
|
|
7264
|
+
const seenPairs = /* @__PURE__ */ new Set();
|
|
7265
|
+
for (const [entityA, associations] of Object.entries(coocIndex.associations)) {
|
|
7266
|
+
for (const [entityB, count] of associations) {
|
|
7267
|
+
if (count < minCount) continue;
|
|
7268
|
+
const pairKey = [entityA, entityB].sort().join("||");
|
|
7269
|
+
if (seenPairs.has(pairKey)) continue;
|
|
7270
|
+
seenPairs.add(pairKey);
|
|
7271
|
+
const aHasNote = resolveTarget(index, entityA) !== null;
|
|
7272
|
+
const bHasNote = resolveTarget(index, entityB) !== null;
|
|
7273
|
+
if (aHasNote && bHasNote) continue;
|
|
7274
|
+
gaps.push({
|
|
7275
|
+
entity_a: entityA,
|
|
7276
|
+
entity_b: entityB,
|
|
7277
|
+
cooccurrence_count: count,
|
|
7278
|
+
a_has_note: aHasNote,
|
|
7279
|
+
b_has_note: bHasNote
|
|
7280
|
+
});
|
|
7281
|
+
}
|
|
7282
|
+
}
|
|
7283
|
+
gaps.sort((a, b) => b.cooccurrence_count - a.cooccurrence_count);
|
|
7284
|
+
const top = gaps.slice(0, limit);
|
|
7285
|
+
const output = {
|
|
7286
|
+
total_gaps: gaps.length,
|
|
7287
|
+
returned_count: top.length,
|
|
7288
|
+
gaps: top
|
|
7289
|
+
};
|
|
7290
|
+
return {
|
|
7291
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }]
|
|
7292
|
+
};
|
|
7293
|
+
}
|
|
7294
|
+
);
|
|
7126
7295
|
}
|
|
7127
7296
|
|
|
7128
7297
|
// src/tools/read/health.ts
|
|
@@ -7488,6 +7657,93 @@ function purgeOldIndexEvents(stateDb2, retentionDays = 90) {
|
|
|
7488
7657
|
return result.changes;
|
|
7489
7658
|
}
|
|
7490
7659
|
|
|
7660
|
+
// src/core/read/sweep.ts
|
|
7661
|
+
var DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1e3;
|
|
7662
|
+
var MIN_SWEEP_INTERVAL_MS = 30 * 1e3;
|
|
7663
|
+
var cachedResults = null;
|
|
7664
|
+
var sweepTimer = null;
|
|
7665
|
+
var sweepRunning = false;
|
|
7666
|
+
function runSweep(index) {
|
|
7667
|
+
const start = Date.now();
|
|
7668
|
+
let deadLinkCount = 0;
|
|
7669
|
+
const deadTargetCounts = /* @__PURE__ */ new Map();
|
|
7670
|
+
for (const note of index.notes.values()) {
|
|
7671
|
+
for (const link of note.outlinks) {
|
|
7672
|
+
if (!resolveTarget(index, link.target)) {
|
|
7673
|
+
deadLinkCount++;
|
|
7674
|
+
const key = link.target.toLowerCase();
|
|
7675
|
+
deadTargetCounts.set(key, (deadTargetCounts.get(key) || 0) + 1);
|
|
7676
|
+
}
|
|
7677
|
+
}
|
|
7678
|
+
}
|
|
7679
|
+
const topDeadTargets = Array.from(deadTargetCounts.entries()).filter(([, count]) => count >= 2).map(([target, wikilink_references]) => ({
|
|
7680
|
+
target,
|
|
7681
|
+
wikilink_references,
|
|
7682
|
+
content_mentions: countFTS5Mentions(target)
|
|
7683
|
+
})).sort((a, b) => b.wikilink_references - a.wikilink_references).slice(0, 10);
|
|
7684
|
+
const linkedCounts = /* @__PURE__ */ new Map();
|
|
7685
|
+
for (const note of index.notes.values()) {
|
|
7686
|
+
for (const link of note.outlinks) {
|
|
7687
|
+
const key = link.target.toLowerCase();
|
|
7688
|
+
linkedCounts.set(key, (linkedCounts.get(key) || 0) + 1);
|
|
7689
|
+
}
|
|
7690
|
+
}
|
|
7691
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7692
|
+
const unlinkedEntities = [];
|
|
7693
|
+
for (const [name, entityPath] of index.entities) {
|
|
7694
|
+
if (seen.has(entityPath)) continue;
|
|
7695
|
+
seen.add(entityPath);
|
|
7696
|
+
const totalMentions = countFTS5Mentions(name);
|
|
7697
|
+
if (totalMentions === 0) continue;
|
|
7698
|
+
const pathKey = entityPath.toLowerCase().replace(/\.md$/, "");
|
|
7699
|
+
const linked = Math.max(linkedCounts.get(name) || 0, linkedCounts.get(pathKey) || 0);
|
|
7700
|
+
const unlinked = Math.max(0, totalMentions - linked - 1);
|
|
7701
|
+
if (unlinked <= 0) continue;
|
|
7702
|
+
const note = index.notes.get(entityPath);
|
|
7703
|
+
const displayName = note?.title || name;
|
|
7704
|
+
unlinkedEntities.push({ entity: displayName, path: entityPath, unlinked_mentions: unlinked });
|
|
7705
|
+
}
|
|
7706
|
+
unlinkedEntities.sort((a, b) => b.unlinked_mentions - a.unlinked_mentions);
|
|
7707
|
+
const results = {
|
|
7708
|
+
last_sweep_at: Date.now(),
|
|
7709
|
+
sweep_duration_ms: Date.now() - start,
|
|
7710
|
+
dead_link_count: deadLinkCount,
|
|
7711
|
+
top_dead_targets: topDeadTargets,
|
|
7712
|
+
top_unlinked_entities: unlinkedEntities.slice(0, 10)
|
|
7713
|
+
};
|
|
7714
|
+
cachedResults = results;
|
|
7715
|
+
return results;
|
|
7716
|
+
}
|
|
7717
|
+
function startSweepTimer(getIndex, intervalMs) {
|
|
7718
|
+
const interval = Math.max(intervalMs ?? DEFAULT_SWEEP_INTERVAL_MS, MIN_SWEEP_INTERVAL_MS);
|
|
7719
|
+
setTimeout(() => {
|
|
7720
|
+
doSweep(getIndex);
|
|
7721
|
+
}, 5e3);
|
|
7722
|
+
sweepTimer = setInterval(() => {
|
|
7723
|
+
doSweep(getIndex);
|
|
7724
|
+
}, interval);
|
|
7725
|
+
if (sweepTimer && typeof sweepTimer === "object" && "unref" in sweepTimer) {
|
|
7726
|
+
sweepTimer.unref();
|
|
7727
|
+
}
|
|
7728
|
+
}
|
|
7729
|
+
function doSweep(getIndex) {
|
|
7730
|
+
if (sweepRunning) return;
|
|
7731
|
+
sweepRunning = true;
|
|
7732
|
+
try {
|
|
7733
|
+
const index = getIndex();
|
|
7734
|
+
if (index && index.notes && index.notes.size > 0) {
|
|
7735
|
+
runSweep(index);
|
|
7736
|
+
}
|
|
7737
|
+
} catch (err) {
|
|
7738
|
+
console.error("[Flywheel] Sweep error:", err);
|
|
7739
|
+
} finally {
|
|
7740
|
+
sweepRunning = false;
|
|
7741
|
+
}
|
|
7742
|
+
}
|
|
7743
|
+
function getSweepResults() {
|
|
7744
|
+
return cachedResults;
|
|
7745
|
+
}
|
|
7746
|
+
|
|
7491
7747
|
// src/tools/read/health.ts
|
|
7492
7748
|
var STALE_THRESHOLD_SECONDS = 300;
|
|
7493
7749
|
function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () => ({}), getStateDb = () => null) {
|
|
@@ -7563,6 +7819,26 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
|
|
|
7563
7819
|
embeddings_count: z3.coerce.number().describe("Number of notes with semantic embeddings"),
|
|
7564
7820
|
tasks_ready: z3.boolean().describe("Whether the task cache is ready to serve queries"),
|
|
7565
7821
|
tasks_building: z3.boolean().describe("Whether the task cache is currently rebuilding"),
|
|
7822
|
+
dead_link_count: z3.coerce.number().describe("Total number of broken/dead wikilinks across the vault"),
|
|
7823
|
+
top_dead_link_targets: z3.array(z3.object({
|
|
7824
|
+
target: z3.string().describe("The dead link target"),
|
|
7825
|
+
mention_count: z3.coerce.number().describe("How many notes reference this dead target")
|
|
7826
|
+
})).describe("Top 5 most-referenced dead link targets (highest-ROI candidates to create)"),
|
|
7827
|
+
sweep: z3.object({
|
|
7828
|
+
last_sweep_at: z3.number().describe("When the last background sweep completed (ms epoch)"),
|
|
7829
|
+
sweep_duration_ms: z3.number().describe("How long the last sweep took"),
|
|
7830
|
+
dead_link_count: z3.number().describe("Dead links found by sweep"),
|
|
7831
|
+
top_dead_targets: z3.array(z3.object({
|
|
7832
|
+
target: z3.string(),
|
|
7833
|
+
wikilink_references: z3.number(),
|
|
7834
|
+
content_mentions: z3.number()
|
|
7835
|
+
})).describe("Top dead link targets with FTS5 content mention counts"),
|
|
7836
|
+
top_unlinked_entities: z3.array(z3.object({
|
|
7837
|
+
entity: z3.string(),
|
|
7838
|
+
path: z3.string(),
|
|
7839
|
+
unlinked_mentions: z3.number()
|
|
7840
|
+
})).describe("Entities with the most unlinked plain-text mentions")
|
|
7841
|
+
}).optional().describe("Background sweep results (graph hygiene metrics, updated every 5 min)"),
|
|
7566
7842
|
recommendations: z3.array(z3.string()).describe("Suggested actions if any issues detected")
|
|
7567
7843
|
};
|
|
7568
7844
|
server2.registerTool(
|
|
@@ -7685,6 +7961,20 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
|
|
|
7685
7961
|
}
|
|
7686
7962
|
}
|
|
7687
7963
|
const ftsState = getFTS5State();
|
|
7964
|
+
let deadLinkCount = 0;
|
|
7965
|
+
const deadTargetCounts = /* @__PURE__ */ new Map();
|
|
7966
|
+
if (indexBuilt) {
|
|
7967
|
+
for (const note of index.notes.values()) {
|
|
7968
|
+
for (const link of note.outlinks) {
|
|
7969
|
+
if (!resolveTarget(index, link.target)) {
|
|
7970
|
+
deadLinkCount++;
|
|
7971
|
+
const key = link.target.toLowerCase();
|
|
7972
|
+
deadTargetCounts.set(key, (deadTargetCounts.get(key) || 0) + 1);
|
|
7973
|
+
}
|
|
7974
|
+
}
|
|
7975
|
+
}
|
|
7976
|
+
}
|
|
7977
|
+
const topDeadLinkTargets = Array.from(deadTargetCounts.entries()).map(([target, mention_count]) => ({ target, mention_count })).sort((a, b) => b.mention_count - a.mention_count).slice(0, 5);
|
|
7688
7978
|
const output = {
|
|
7689
7979
|
status,
|
|
7690
7980
|
schema_version: SCHEMA_VERSION,
|
|
@@ -7712,6 +8002,9 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
|
|
|
7712
8002
|
embeddings_count: getEmbeddingsCount(),
|
|
7713
8003
|
tasks_ready: isTaskCacheReady(),
|
|
7714
8004
|
tasks_building: isTaskCacheBuilding(),
|
|
8005
|
+
dead_link_count: deadLinkCount,
|
|
8006
|
+
top_dead_link_targets: topDeadLinkTargets,
|
|
8007
|
+
sweep: getSweepResults() ?? void 0,
|
|
7715
8008
|
recommendations
|
|
7716
8009
|
};
|
|
7717
8010
|
return {
|
|
@@ -8205,6 +8498,14 @@ function generateAliasCandidates(entityName, existingAliases) {
|
|
|
8205
8498
|
candidates.push({ candidate: short, type: "short_form" });
|
|
8206
8499
|
}
|
|
8207
8500
|
}
|
|
8501
|
+
const STOPWORDS2 = /* @__PURE__ */ new Set(["the", "and", "for", "with", "from", "into", "that", "this", "are", "was", "has", "its"]);
|
|
8502
|
+
for (const word of words) {
|
|
8503
|
+
if (word.length < 4) continue;
|
|
8504
|
+
if (STOPWORDS2.has(word.toLowerCase())) continue;
|
|
8505
|
+
if (existing.has(word.toLowerCase())) continue;
|
|
8506
|
+
if (words.length >= 3 && word === words[0]) continue;
|
|
8507
|
+
candidates.push({ candidate: word, type: "name_fragment" });
|
|
8508
|
+
}
|
|
8208
8509
|
}
|
|
8209
8510
|
return candidates;
|
|
8210
8511
|
}
|
|
@@ -8231,6 +8532,7 @@ function suggestEntityAliases(stateDb2, folder) {
|
|
|
8231
8532
|
mentions = result?.cnt ?? 0;
|
|
8232
8533
|
} catch {
|
|
8233
8534
|
}
|
|
8535
|
+
if (type === "name_fragment" && mentions < 5) continue;
|
|
8234
8536
|
suggestions.push({
|
|
8235
8537
|
entity: row.name,
|
|
8236
8538
|
entity_path: row.path,
|
|
@@ -8761,6 +9063,61 @@ function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfi
|
|
|
8761
9063
|
};
|
|
8762
9064
|
}
|
|
8763
9065
|
);
|
|
9066
|
+
server2.registerTool(
|
|
9067
|
+
"unlinked_mentions_report",
|
|
9068
|
+
{
|
|
9069
|
+
title: "Unlinked Mentions Report",
|
|
9070
|
+
description: "Find which entities have the most unlinked mentions across the vault \u2014 highest-ROI linking opportunities. Uses FTS5 to count mentions and subtracts known wikilinks.",
|
|
9071
|
+
inputSchema: {
|
|
9072
|
+
limit: z5.coerce.number().default(20).describe("Maximum entities to return (default 20)")
|
|
9073
|
+
}
|
|
9074
|
+
},
|
|
9075
|
+
async ({ limit: requestedLimit }) => {
|
|
9076
|
+
requireIndex();
|
|
9077
|
+
const index = getIndex();
|
|
9078
|
+
const limit = Math.min(requestedLimit ?? 20, 100);
|
|
9079
|
+
const linkedCounts = /* @__PURE__ */ new Map();
|
|
9080
|
+
for (const note of index.notes.values()) {
|
|
9081
|
+
for (const link of note.outlinks) {
|
|
9082
|
+
const key = link.target.toLowerCase();
|
|
9083
|
+
linkedCounts.set(key, (linkedCounts.get(key) || 0) + 1);
|
|
9084
|
+
}
|
|
9085
|
+
}
|
|
9086
|
+
const results = [];
|
|
9087
|
+
const seen = /* @__PURE__ */ new Set();
|
|
9088
|
+
for (const [name, entityPath] of index.entities) {
|
|
9089
|
+
if (seen.has(entityPath)) continue;
|
|
9090
|
+
seen.add(entityPath);
|
|
9091
|
+
const totalMentions = countFTS5Mentions(name);
|
|
9092
|
+
if (totalMentions === 0) continue;
|
|
9093
|
+
const pathKey = entityPath.toLowerCase().replace(/\.md$/, "");
|
|
9094
|
+
const linkedByName = linkedCounts.get(name) || 0;
|
|
9095
|
+
const linkedByPath = linkedCounts.get(pathKey) || 0;
|
|
9096
|
+
const linked = Math.max(linkedByName, linkedByPath);
|
|
9097
|
+
const unlinked = Math.max(0, totalMentions - linked - 1);
|
|
9098
|
+
if (unlinked <= 0) continue;
|
|
9099
|
+
const note = index.notes.get(entityPath);
|
|
9100
|
+
const displayName = note?.title || name;
|
|
9101
|
+
results.push({
|
|
9102
|
+
entity: displayName,
|
|
9103
|
+
path: entityPath,
|
|
9104
|
+
total_mentions: totalMentions,
|
|
9105
|
+
linked_mentions: linked,
|
|
9106
|
+
unlinked_mentions: unlinked
|
|
9107
|
+
});
|
|
9108
|
+
}
|
|
9109
|
+
results.sort((a, b) => b.unlinked_mentions - a.unlinked_mentions);
|
|
9110
|
+
const top = results.slice(0, limit);
|
|
9111
|
+
const output = {
|
|
9112
|
+
total_entities_checked: seen.size,
|
|
9113
|
+
entities_with_unlinked: results.length,
|
|
9114
|
+
top_entities: top
|
|
9115
|
+
};
|
|
9116
|
+
return {
|
|
9117
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }]
|
|
9118
|
+
};
|
|
9119
|
+
}
|
|
9120
|
+
);
|
|
8764
9121
|
}
|
|
8765
9122
|
|
|
8766
9123
|
// src/tools/read/primitives.ts
|
|
@@ -12119,8 +12476,12 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
|
|
|
12119
12476
|
return formatMcpResult(errorResult(notePath, `Template not found: ${template}`));
|
|
12120
12477
|
}
|
|
12121
12478
|
}
|
|
12479
|
+
const now = /* @__PURE__ */ new Date();
|
|
12122
12480
|
if (!effectiveFrontmatter.date) {
|
|
12123
|
-
effectiveFrontmatter.date =
|
|
12481
|
+
effectiveFrontmatter.date = now.toISOString().split("T")[0];
|
|
12482
|
+
}
|
|
12483
|
+
if (!effectiveFrontmatter.created) {
|
|
12484
|
+
effectiveFrontmatter.created = now.toISOString();
|
|
12124
12485
|
}
|
|
12125
12486
|
const warnings = [];
|
|
12126
12487
|
const noteName = path21.basename(notePath, ".md");
|
|
@@ -15931,6 +16292,7 @@ registerSystemTools(
|
|
|
15931
16292
|
() => vaultPath,
|
|
15932
16293
|
(newConfig) => {
|
|
15933
16294
|
flywheelConfig = newConfig;
|
|
16295
|
+
setWikilinkConfig(newConfig);
|
|
15934
16296
|
},
|
|
15935
16297
|
() => stateDb
|
|
15936
16298
|
);
|
|
@@ -15957,6 +16319,7 @@ registerConfigTools(
|
|
|
15957
16319
|
() => flywheelConfig,
|
|
15958
16320
|
(newConfig) => {
|
|
15959
16321
|
flywheelConfig = newConfig;
|
|
16322
|
+
setWikilinkConfig(newConfig);
|
|
15960
16323
|
},
|
|
15961
16324
|
() => stateDb
|
|
15962
16325
|
);
|
|
@@ -16139,6 +16502,7 @@ async function runPostIndexWork(index) {
|
|
|
16139
16502
|
saveConfig(stateDb, inferred, existing);
|
|
16140
16503
|
}
|
|
16141
16504
|
flywheelConfig = loadConfig(stateDb);
|
|
16505
|
+
setWikilinkConfig(flywheelConfig);
|
|
16142
16506
|
const configKeys = Object.keys(flywheelConfig).filter((k) => flywheelConfig[k] != null);
|
|
16143
16507
|
serverLog("config", `Config inferred: ${configKeys.join(", ")}`);
|
|
16144
16508
|
if (stateDb) {
|
|
@@ -16376,6 +16740,8 @@ async function runPostIndexWork(index) {
|
|
|
16376
16740
|
watcher.start();
|
|
16377
16741
|
serverLog("watcher", "File watcher started");
|
|
16378
16742
|
}
|
|
16743
|
+
startSweepTimer(() => vaultIndex);
|
|
16744
|
+
serverLog("server", "Sweep timer started (5 min interval)");
|
|
16379
16745
|
const postDuration = Date.now() - postStart;
|
|
16380
16746
|
serverLog("server", `Post-index work complete in ${postDuration}ms`);
|
|
16381
16747
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@velvetmonkey/flywheel-memory",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.34",
|
|
4
4
|
"description": "MCP server that gives Claude full read/write access to your Obsidian vault. 42 tools for search, backlinks, graph queries, mutations, and hybrid semantic search.",
|
|
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.34",
|
|
54
54
|
"better-sqlite3": "^11.0.0",
|
|
55
55
|
"chokidar": "^4.0.0",
|
|
56
56
|
"gray-matter": "^4.0.3",
|