@velvetmonkey/flywheel-memory 2.0.32 → 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.
Files changed (2) hide show
  1. package/dist/index.js +479 -37
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -2486,7 +2486,11 @@ import {
2486
2486
  var DEFAULT_CONFIG = {
2487
2487
  exclude_task_tags: [],
2488
2488
  exclude_analysis_tags: [],
2489
- exclude_entities: []
2489
+ exclude_entities: [],
2490
+ exclude_entity_folders: [],
2491
+ wikilink_strictness: "balanced",
2492
+ implicit_detection: true,
2493
+ adaptive_strictness: true
2490
2494
  };
2491
2495
  function loadConfig(stateDb2) {
2492
2496
  if (stateDb2) {
@@ -3403,10 +3407,10 @@ function getAllFeedbackBoosts(stateDb2, folder) {
3403
3407
  for (const row of globalRows) {
3404
3408
  let accuracy;
3405
3409
  let sampleCount;
3406
- const fs30 = folderStats?.get(row.entity);
3407
- if (fs30 && fs30.count >= FEEDBACK_BOOST_MIN_SAMPLES) {
3408
- accuracy = fs30.accuracy;
3409
- sampleCount = fs30.count;
3410
+ const fs31 = folderStats?.get(row.entity);
3411
+ if (fs31 && fs31.count >= FEEDBACK_BOOST_MIN_SAMPLES) {
3412
+ accuracy = fs31.accuracy;
3413
+ sampleCount = fs31.count;
3410
3414
  } else {
3411
3415
  accuracy = row.correct_count / row.total;
3412
3416
  sampleCount = row.total;
@@ -4901,6 +4905,24 @@ function setWriteStateDb(stateDb2) {
4901
4905
  function getWriteStateDb() {
4902
4906
  return moduleStateDb4;
4903
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
+ }
4904
4926
  var entityIndex = null;
4905
4927
  var indexReady = false;
4906
4928
  var indexError2 = null;
@@ -5061,9 +5083,11 @@ function processWikilinks(content, notePath) {
5061
5083
  firstOccurrenceOnly: true,
5062
5084
  caseInsensitive: true
5063
5085
  });
5086
+ const implicitEnabled = moduleConfig?.implicit_detection !== false;
5087
+ const implicitPatterns = moduleConfig?.implicit_patterns?.length ? moduleConfig.implicit_patterns : [...ALL_IMPLICIT_PATTERNS];
5064
5088
  const implicitMatches = detectImplicitEntities(result.content, {
5065
- detectImplicit: true,
5066
- implicitPatterns: ["proper-nouns", "single-caps", "quoted-terms", "camel-case", "acronyms"],
5089
+ detectImplicit: implicitEnabled,
5090
+ implicitPatterns,
5067
5091
  minEntityLength: 3
5068
5092
  });
5069
5093
  const alreadyLinked = new Set(
@@ -5078,12 +5102,22 @@ function processWikilinks(content, notePath) {
5078
5102
  }
5079
5103
  }
5080
5104
  const currentNoteName = notePath ? notePath.replace(/\.md$/, "").split("/").pop()?.toLowerCase() : null;
5081
- const newImplicits = implicitMatches.filter((m) => {
5105
+ let newImplicits = implicitMatches.filter((m) => {
5082
5106
  const normalized = m.text.toLowerCase();
5083
5107
  if (alreadyLinked.has(normalized)) return false;
5084
5108
  if (currentNoteName && normalized === currentNoteName) return false;
5085
5109
  return true;
5086
5110
  });
5111
+ const nonOverlapping = [];
5112
+ for (const match of newImplicits) {
5113
+ const overlaps = nonOverlapping.some(
5114
+ (existing) => match.start >= existing.start && match.start < existing.end || match.end > existing.start && match.end <= existing.end || match.start <= existing.start && match.end >= existing.end
5115
+ );
5116
+ if (!overlaps) {
5117
+ nonOverlapping.push(match);
5118
+ }
5119
+ }
5120
+ newImplicits = nonOverlapping;
5087
5121
  if (newImplicits.length > 0) {
5088
5122
  let processedContent = result.content;
5089
5123
  for (let i = newImplicits.length - 1; i >= 0; i--) {
@@ -5283,7 +5317,6 @@ var STRICTNESS_CONFIGS = {
5283
5317
  // Standard bonus for exact matches
5284
5318
  }
5285
5319
  };
5286
- var DEFAULT_STRICTNESS = "conservative";
5287
5320
  var TYPE_BOOST = {
5288
5321
  people: 5,
5289
5322
  // Names are high value for connections
@@ -5470,7 +5503,7 @@ async function suggestRelatedLinks(content, options = {}) {
5470
5503
  const {
5471
5504
  maxSuggestions = 3,
5472
5505
  excludeLinked = true,
5473
- strictness = DEFAULT_STRICTNESS,
5506
+ strictness = getEffectiveStrictness(options.notePath),
5474
5507
  notePath,
5475
5508
  detail = false
5476
5509
  } = options;
@@ -6035,6 +6068,17 @@ function searchFTS5(_vaultPath, query, limit = 10) {
6035
6068
  function getFTS5State() {
6036
6069
  return { ...state };
6037
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
+ }
6038
6082
 
6039
6083
  // src/core/read/taskCache.ts
6040
6084
  import * as path10 from "path";
@@ -7046,12 +7090,13 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
7046
7090
  inputSchema: {
7047
7091
  path: z2.string().optional().describe("Path to a specific note to validate. If omitted, validates all notes."),
7048
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[]."),
7049
7094
  limit: z2.coerce.number().default(50).describe("Maximum number of broken links to return"),
7050
7095
  offset: z2.coerce.number().default(0).describe("Number of broken links to skip (for pagination)")
7051
7096
  },
7052
7097
  outputSchema: ValidateLinksOutputSchema
7053
7098
  },
7054
- async ({ path: notePath, typos_only, limit: requestedLimit, offset }) => {
7099
+ async ({ path: notePath, typos_only, group_by_target, limit: requestedLimit, offset }) => {
7055
7100
  const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
7056
7101
  const index = getIndex();
7057
7102
  const allBroken = [];
@@ -7092,6 +7137,41 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
7092
7137
  }
7093
7138
  }
7094
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
+ }
7095
7175
  const broken = allBroken.slice(offset, offset + limit);
7096
7176
  const output = {
7097
7177
  scope: notePath || "all",
@@ -7112,6 +7192,106 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
7112
7192
  };
7113
7193
  }
7114
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
+ );
7115
7295
  }
7116
7296
 
7117
7297
  // src/tools/read/health.ts
@@ -7414,7 +7594,7 @@ function computeEntityDiff(before, after) {
7414
7594
  const alias_changes = [];
7415
7595
  for (const [key, entity] of afterMap) {
7416
7596
  if (!beforeMap.has(key)) {
7417
- added.push(entity.name);
7597
+ added.push({ name: entity.name, category: entity.category, path: entity.path });
7418
7598
  } else {
7419
7599
  const prev = beforeMap.get(key);
7420
7600
  const prevAliases = JSON.stringify(prev.aliases.sort());
@@ -7426,7 +7606,7 @@ function computeEntityDiff(before, after) {
7426
7606
  }
7427
7607
  for (const [key, entity] of beforeMap) {
7428
7608
  if (!afterMap.has(key)) {
7429
- removed.push(entity.name);
7609
+ removed.push({ name: entity.name, category: entity.category, path: entity.path });
7430
7610
  }
7431
7611
  }
7432
7612
  return { added, removed, alias_changes };
@@ -7477,6 +7657,93 @@ function purgeOldIndexEvents(stateDb2, retentionDays = 90) {
7477
7657
  return result.changes;
7478
7658
  }
7479
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
+
7480
7747
  // src/tools/read/health.ts
7481
7748
  var STALE_THRESHOLD_SECONDS = 300;
7482
7749
  function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () => ({}), getStateDb = () => null) {
@@ -7520,6 +7787,7 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
7520
7787
  trigger: z3.string(),
7521
7788
  duration_ms: z3.number(),
7522
7789
  files_changed: z3.number().nullable(),
7790
+ changed_paths: z3.array(z3.string()).nullable(),
7523
7791
  steps: z3.array(z3.object({
7524
7792
  name: z3.string(),
7525
7793
  duration_ms: z3.number(),
@@ -7529,6 +7797,21 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
7529
7797
  skip_reason: z3.string().optional()
7530
7798
  }))
7531
7799
  }).optional().describe("Most recent watcher pipeline run with per-step timing"),
7800
+ recent_pipelines: z3.array(z3.object({
7801
+ timestamp: z3.number(),
7802
+ trigger: z3.string(),
7803
+ duration_ms: z3.number(),
7804
+ files_changed: z3.number().nullable(),
7805
+ changed_paths: z3.array(z3.string()).nullable(),
7806
+ steps: z3.array(z3.object({
7807
+ name: z3.string(),
7808
+ duration_ms: z3.number(),
7809
+ input: z3.record(z3.unknown()),
7810
+ output: z3.record(z3.unknown()),
7811
+ skipped: z3.boolean().optional(),
7812
+ skip_reason: z3.string().optional()
7813
+ }))
7814
+ })).optional().describe("Up to 5 most recent pipeline runs with steps data"),
7532
7815
  fts5_ready: z3.boolean().describe("Whether the FTS5 keyword search index is ready"),
7533
7816
  fts5_building: z3.boolean().describe("Whether the FTS5 keyword search index is currently building"),
7534
7817
  embeddings_building: z3.boolean().describe("Whether semantic embeddings are currently building"),
@@ -7536,6 +7819,26 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
7536
7819
  embeddings_count: z3.coerce.number().describe("Number of notes with semantic embeddings"),
7537
7820
  tasks_ready: z3.boolean().describe("Whether the task cache is ready to serve queries"),
7538
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)"),
7539
7842
  recommendations: z3.array(z3.string()).describe("Suggested actions if any issues detected")
7540
7843
  };
7541
7844
  server2.registerTool(
@@ -7626,6 +7929,7 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
7626
7929
  }
7627
7930
  }
7628
7931
  let lastPipeline;
7932
+ let recentPipelines;
7629
7933
  if (stateDb2) {
7630
7934
  try {
7631
7935
  const evt = getRecentPipelineEvent(stateDb2);
@@ -7635,13 +7939,42 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
7635
7939
  trigger: evt.trigger,
7636
7940
  duration_ms: evt.duration_ms,
7637
7941
  files_changed: evt.files_changed,
7942
+ changed_paths: evt.changed_paths,
7638
7943
  steps: evt.steps
7639
7944
  };
7640
7945
  }
7641
7946
  } catch {
7642
7947
  }
7948
+ try {
7949
+ const events = getRecentIndexEvents(stateDb2, 10).filter((e) => e.steps && e.steps.length > 0).slice(0, 5);
7950
+ if (events.length > 0) {
7951
+ recentPipelines = events.map((e) => ({
7952
+ timestamp: e.timestamp,
7953
+ trigger: e.trigger,
7954
+ duration_ms: e.duration_ms,
7955
+ files_changed: e.files_changed,
7956
+ changed_paths: e.changed_paths,
7957
+ steps: e.steps
7958
+ }));
7959
+ }
7960
+ } catch {
7961
+ }
7643
7962
  }
7644
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);
7645
7978
  const output = {
7646
7979
  status,
7647
7980
  schema_version: SCHEMA_VERSION,
@@ -7661,6 +7994,7 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
7661
7994
  config: configInfo,
7662
7995
  last_rebuild: lastRebuild,
7663
7996
  last_pipeline: lastPipeline,
7997
+ recent_pipelines: recentPipelines,
7664
7998
  fts5_ready: ftsState.ready,
7665
7999
  fts5_building: ftsState.building,
7666
8000
  embeddings_building: isEmbeddingsBuilding(),
@@ -7668,6 +8002,9 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
7668
8002
  embeddings_count: getEmbeddingsCount(),
7669
8003
  tasks_ready: isTaskCacheReady(),
7670
8004
  tasks_building: isTaskCacheBuilding(),
8005
+ dead_link_count: deadLinkCount,
8006
+ top_dead_link_targets: topDeadLinkTargets,
8007
+ sweep: getSweepResults() ?? void 0,
7671
8008
  recommendations
7672
8009
  };
7673
8010
  return {
@@ -8161,6 +8498,14 @@ function generateAliasCandidates(entityName, existingAliases) {
8161
8498
  candidates.push({ candidate: short, type: "short_form" });
8162
8499
  }
8163
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
+ }
8164
8509
  }
8165
8510
  return candidates;
8166
8511
  }
@@ -8187,6 +8532,7 @@ function suggestEntityAliases(stateDb2, folder) {
8187
8532
  mentions = result?.cnt ?? 0;
8188
8533
  } catch {
8189
8534
  }
8535
+ if (type === "name_fragment" && mentions < 5) continue;
8190
8536
  suggestions.push({
8191
8537
  entity: row.name,
8192
8538
  entity_path: row.path,
@@ -8717,6 +9063,61 @@ function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfi
8717
9063
  };
8718
9064
  }
8719
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
+ );
8720
9121
  }
8721
9122
 
8722
9123
  // src/tools/read/primitives.ts
@@ -12075,8 +12476,12 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
12075
12476
  return formatMcpResult(errorResult(notePath, `Template not found: ${template}`));
12076
12477
  }
12077
12478
  }
12479
+ const now = /* @__PURE__ */ new Date();
12078
12480
  if (!effectiveFrontmatter.date) {
12079
- effectiveFrontmatter.date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
12481
+ effectiveFrontmatter.date = now.toISOString().split("T")[0];
12482
+ }
12483
+ if (!effectiveFrontmatter.created) {
12484
+ effectiveFrontmatter.created = now.toISOString();
12080
12485
  }
12081
12486
  const warnings = [];
12082
12487
  const noteName = path21.basename(notePath, ".md");
@@ -15542,6 +15947,9 @@ function registerMergeTools2(server2, getStateDb) {
15542
15947
  );
15543
15948
  }
15544
15949
 
15950
+ // src/index.ts
15951
+ import * as fs30 from "node:fs/promises";
15952
+
15545
15953
  // src/resources/vault.ts
15546
15954
  function registerVaultResources(server2, getIndex) {
15547
15955
  server2.registerResource(
@@ -15884,6 +16292,7 @@ registerSystemTools(
15884
16292
  () => vaultPath,
15885
16293
  (newConfig) => {
15886
16294
  flywheelConfig = newConfig;
16295
+ setWikilinkConfig(newConfig);
15887
16296
  },
15888
16297
  () => stateDb
15889
16298
  );
@@ -15910,6 +16319,7 @@ registerConfigTools(
15910
16319
  () => flywheelConfig,
15911
16320
  (newConfig) => {
15912
16321
  flywheelConfig = newConfig;
16322
+ setWikilinkConfig(newConfig);
15913
16323
  },
15914
16324
  () => stateDb
15915
16325
  );
@@ -16034,31 +16444,14 @@ async function main() {
16034
16444
  }
16035
16445
  }
16036
16446
  }
16447
+ var DEFAULT_ENTITY_EXCLUDE_FOLDERS = ["node_modules", "templates", "attachments", "tmp"];
16037
16448
  async function updateEntitiesInStateDb() {
16038
16449
  if (!stateDb) return;
16039
16450
  try {
16451
+ const config = loadConfig(stateDb);
16452
+ const excludeFolders = config.exclude_entity_folders?.length ? config.exclude_entity_folders : DEFAULT_ENTITY_EXCLUDE_FOLDERS;
16040
16453
  const entityIndex2 = await scanVaultEntities3(vaultPath, {
16041
- excludeFolders: [
16042
- "daily-notes",
16043
- "daily",
16044
- "weekly",
16045
- "weekly-notes",
16046
- "monthly",
16047
- "monthly-notes",
16048
- "quarterly",
16049
- "yearly-notes",
16050
- "periodic",
16051
- "journal",
16052
- "inbox",
16053
- "templates",
16054
- "attachments",
16055
- "tmp",
16056
- "clippings",
16057
- "readwise",
16058
- "articles",
16059
- "bookmarks",
16060
- "web-clips"
16061
- ]
16454
+ excludeFolders
16062
16455
  });
16063
16456
  stateDb.replaceAllEntities(entityIndex2);
16064
16457
  serverLog("index", `Updated ${entityIndex2._metadata.total_entities} entities in StateDb`);
@@ -16109,6 +16502,7 @@ async function runPostIndexWork(index) {
16109
16502
  saveConfig(stateDb, inferred, existing);
16110
16503
  }
16111
16504
  flywheelConfig = loadConfig(stateDb);
16505
+ setWikilinkConfig(flywheelConfig);
16112
16506
  const configKeys = Object.keys(flywheelConfig).filter((k) => flywheelConfig[k] != null);
16113
16507
  serverLog("config", `Config inferred: ${configKeys.join(", ")}`);
16114
16508
  if (stateDb) {
@@ -16177,9 +16571,22 @@ async function runPostIndexWork(index) {
16177
16571
  const entityDiff = computeEntityDiff(entitiesBefore, entitiesAfter);
16178
16572
  tracker.end({ entity_count: entitiesAfter.length, ...entityDiff });
16179
16573
  serverLog("watcher", `Entity scan: ${entitiesAfter.length} entities`);
16574
+ const hubBefore = /* @__PURE__ */ new Map();
16575
+ if (stateDb) {
16576
+ const rows = stateDb.db.prepare("SELECT name, hub_score FROM entities").all();
16577
+ for (const r of rows) hubBefore.set(r.name, r.hub_score);
16578
+ }
16180
16579
  tracker.start("hub_scores", { entity_count: entitiesAfter.length });
16181
16580
  const hubUpdated = await exportHubScores(vaultIndex, stateDb);
16182
- tracker.end({ updated: hubUpdated ?? 0 });
16581
+ const hubDiffs = [];
16582
+ if (stateDb) {
16583
+ const rows = stateDb.db.prepare("SELECT name, hub_score FROM entities").all();
16584
+ for (const r of rows) {
16585
+ const prev = hubBefore.get(r.name) ?? 0;
16586
+ if (prev !== r.hub_score) hubDiffs.push({ entity: r.name, before: prev, after: r.hub_score });
16587
+ }
16588
+ }
16589
+ tracker.end({ updated: hubUpdated ?? 0, diffs: hubDiffs.slice(0, 10) });
16183
16590
  serverLog("watcher", `Hub scores: ${hubUpdated ?? 0} updated`);
16184
16591
  if (hasEmbeddingsIndex()) {
16185
16592
  tracker.start("note_embeddings", { files: batch.events.length });
@@ -16206,6 +16613,7 @@ async function runPostIndexWork(index) {
16206
16613
  if (hasEntityEmbeddingsIndex() && stateDb) {
16207
16614
  tracker.start("entity_embeddings", { files: batch.events.length });
16208
16615
  let entEmbUpdated = 0;
16616
+ const entEmbNames = [];
16209
16617
  try {
16210
16618
  const allEntities = getAllEntitiesFromDb3(stateDb);
16211
16619
  for (const event of batch.events) {
@@ -16219,11 +16627,12 @@ async function runPostIndexWork(index) {
16219
16627
  aliases: entity.aliases
16220
16628
  }, vaultPath);
16221
16629
  entEmbUpdated++;
16630
+ entEmbNames.push(entity.name);
16222
16631
  }
16223
16632
  }
16224
16633
  } catch {
16225
16634
  }
16226
- tracker.end({ updated: entEmbUpdated });
16635
+ tracker.end({ updated: entEmbUpdated, updated_entities: entEmbNames.slice(0, 10) });
16227
16636
  serverLog("watcher", `Entity embeddings: ${entEmbUpdated} updated`);
16228
16637
  } else {
16229
16638
  tracker.skip("entity_embeddings", !stateDb ? "no stateDb" : "not built");
@@ -16258,6 +16667,37 @@ async function runPostIndexWork(index) {
16258
16667
  }
16259
16668
  tracker.end({ updated: taskUpdated, removed: taskRemoved });
16260
16669
  serverLog("watcher", `Task cache: ${taskUpdated} updated, ${taskRemoved} removed`);
16670
+ tracker.start("wikilink_check", { files: batch.events.length });
16671
+ const trackedLinks = [];
16672
+ if (stateDb) {
16673
+ for (const event of batch.events) {
16674
+ if (event.type === "delete" || !event.path.endsWith(".md")) continue;
16675
+ try {
16676
+ const apps = getTrackedApplications(stateDb, event.path);
16677
+ if (apps.length > 0) trackedLinks.push({ file: event.path, entities: apps });
16678
+ } catch {
16679
+ }
16680
+ }
16681
+ }
16682
+ tracker.end({ tracked: trackedLinks });
16683
+ serverLog("watcher", `Wikilink check: ${trackedLinks.reduce((s, t) => s + t.entities.length, 0)} tracked links in ${trackedLinks.length} files`);
16684
+ tracker.start("implicit_feedback", { files: batch.events.length });
16685
+ const feedbackResults = [];
16686
+ if (stateDb) {
16687
+ for (const event of batch.events) {
16688
+ if (event.type === "delete" || !event.path.endsWith(".md")) continue;
16689
+ try {
16690
+ const content = await fs30.readFile(path29.join(vaultPath, event.path), "utf-8");
16691
+ const removed = processImplicitFeedback(stateDb, event.path, content);
16692
+ for (const entity of removed) feedbackResults.push({ entity, file: event.path });
16693
+ } catch {
16694
+ }
16695
+ }
16696
+ }
16697
+ tracker.end({ removals: feedbackResults });
16698
+ if (feedbackResults.length > 0) {
16699
+ serverLog("watcher", `Implicit feedback: ${feedbackResults.length} removals detected`);
16700
+ }
16261
16701
  const duration = Date.now() - batchStart;
16262
16702
  if (stateDb) {
16263
16703
  recordIndexEvent(stateDb, {
@@ -16300,6 +16740,8 @@ async function runPostIndexWork(index) {
16300
16740
  watcher.start();
16301
16741
  serverLog("watcher", "File watcher started");
16302
16742
  }
16743
+ startSweepTimer(() => vaultIndex);
16744
+ serverLog("server", "Sweep timer started (5 min interval)");
16303
16745
  const postDuration = Date.now() - postStart;
16304
16746
  serverLog("server", `Post-index work complete in ${postDuration}ms`);
16305
16747
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.0.32",
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.32",
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",