@velvetmonkey/flywheel-memory 2.0.33 → 2.0.35

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 +855 -61
  2. package/package.json +4 -2
package/dist/index.js CHANGED
@@ -832,7 +832,8 @@ function createContext(variables = {}) {
832
832
  today: formatDate2(now),
833
833
  time: formatTime(now),
834
834
  date: formatDate2(now)
835
- }
835
+ },
836
+ steps: {}
836
837
  };
837
838
  }
838
839
  function resolvePath(obj, path30) {
@@ -878,6 +879,9 @@ function resolveExpression(expr, context) {
878
879
  if (trimmed.startsWith("builtins.")) {
879
880
  return resolvePath(context.builtins, trimmed.slice("builtins.".length));
880
881
  }
882
+ if (trimmed.startsWith("steps.")) {
883
+ return resolvePath(context.steps, trimmed.slice("steps.".length));
884
+ }
881
885
  return resolvePath(context.variables, trimmed);
882
886
  }
883
887
  function interpolate(template, context) {
@@ -1056,7 +1060,7 @@ function validatePolicySchema(policy) {
1056
1060
  const match = ref.match(/\{\{(?:variables\.)?(\w+)/);
1057
1061
  if (match) {
1058
1062
  const varName = match[1];
1059
- if (["now", "today", "time", "date", "conditions"].includes(varName)) {
1063
+ if (["now", "today", "time", "date", "conditions", "steps"].includes(varName)) {
1060
1064
  continue;
1061
1065
  }
1062
1066
  if (!varNames.has(varName)) {
@@ -1549,6 +1553,9 @@ var init_taskHelpers = __esm({
1549
1553
 
1550
1554
  // src/index.ts
1551
1555
  import * as path29 from "path";
1556
+ import { readFileSync as readFileSync4, realpathSync } from "fs";
1557
+ import { fileURLToPath } from "url";
1558
+ import { dirname as dirname4, join as join16 } from "path";
1552
1559
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1553
1560
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1554
1561
 
@@ -2260,7 +2267,10 @@ async function buildVaultIndexInternal(vaultPath2, startTime, onProgress) {
2260
2267
  }
2261
2268
  }
2262
2269
  if (parseErrors.length > 0) {
2263
- console.error(`Failed to parse ${parseErrors.length} files`);
2270
+ console.error(`Failed to parse ${parseErrors.length} files:`);
2271
+ for (const errorPath of parseErrors) {
2272
+ console.error(` - ${errorPath}`);
2273
+ }
2264
2274
  }
2265
2275
  const entities = /* @__PURE__ */ new Map();
2266
2276
  for (const note of notes.values()) {
@@ -2487,7 +2497,10 @@ var DEFAULT_CONFIG = {
2487
2497
  exclude_task_tags: [],
2488
2498
  exclude_analysis_tags: [],
2489
2499
  exclude_entities: [],
2490
- exclude_entity_folders: []
2500
+ exclude_entity_folders: [],
2501
+ wikilink_strictness: "balanced",
2502
+ implicit_detection: true,
2503
+ adaptive_strictness: true
2491
2504
  };
2492
2505
  function loadConfig(stateDb2) {
2493
2506
  if (stateDb2) {
@@ -3238,9 +3251,14 @@ var FEEDBACK_BOOST_TIERS = [
3238
3251
  { minAccuracy: 0, minSamples: 5, boost: -4 }
3239
3252
  ];
3240
3253
  function recordFeedback(stateDb2, entity, context, notePath, correct) {
3241
- stateDb2.db.prepare(
3242
- "INSERT INTO wikilink_feedback (entity, context, note_path, correct) VALUES (?, ?, ?, ?)"
3243
- ).run(entity, context, notePath, correct ? 1 : 0);
3254
+ try {
3255
+ stateDb2.db.prepare(
3256
+ "INSERT INTO wikilink_feedback (entity, context, note_path, correct) VALUES (?, ?, ?, ?)"
3257
+ ).run(entity, context, notePath, correct ? 1 : 0);
3258
+ } catch (e) {
3259
+ console.error(`[Flywheel] recordFeedback failed for entity="${entity}": ${e}`);
3260
+ throw e;
3261
+ }
3244
3262
  }
3245
3263
  function getFeedback(stateDb2, entity, limit = 20) {
3246
3264
  let rows;
@@ -3551,6 +3569,159 @@ function getDashboardData(stateDb2) {
3551
3569
  }))
3552
3570
  };
3553
3571
  }
3572
+ function getEntityScoreTimeline(stateDb2, entityName, daysBack = 30, limit = 100) {
3573
+ const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1e3;
3574
+ const rows = stateDb2.db.prepare(`
3575
+ SELECT timestamp, total_score, breakdown_json, note_path, passed, threshold
3576
+ FROM suggestion_events
3577
+ WHERE entity = ? AND timestamp >= ?
3578
+ ORDER BY timestamp ASC
3579
+ LIMIT ?
3580
+ `).all(entityName, cutoff, limit);
3581
+ return rows.map((r) => ({
3582
+ timestamp: r.timestamp,
3583
+ score: r.total_score,
3584
+ breakdown: JSON.parse(r.breakdown_json),
3585
+ notePath: r.note_path,
3586
+ passed: r.passed === 1,
3587
+ threshold: r.threshold
3588
+ }));
3589
+ }
3590
+ function getLayerContributionTimeseries(stateDb2, granularity = "day", daysBack = 30) {
3591
+ const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1e3;
3592
+ const rows = stateDb2.db.prepare(`
3593
+ SELECT timestamp, breakdown_json
3594
+ FROM suggestion_events
3595
+ WHERE timestamp >= ?
3596
+ ORDER BY timestamp ASC
3597
+ `).all(cutoff);
3598
+ const buckets = /* @__PURE__ */ new Map();
3599
+ for (const row of rows) {
3600
+ const date = new Date(row.timestamp);
3601
+ let bucket;
3602
+ if (granularity === "week") {
3603
+ const jan4 = new Date(date.getFullYear(), 0, 4);
3604
+ const weekNum = Math.ceil(((date.getTime() - jan4.getTime()) / 864e5 + jan4.getDay() + 1) / 7);
3605
+ bucket = `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`;
3606
+ } else {
3607
+ bucket = date.toISOString().slice(0, 10);
3608
+ }
3609
+ if (!buckets.has(bucket)) {
3610
+ buckets.set(bucket, { count: 0, layers: {} });
3611
+ }
3612
+ const acc = buckets.get(bucket);
3613
+ acc.count++;
3614
+ const breakdown = JSON.parse(row.breakdown_json);
3615
+ const layerMap = {
3616
+ contentMatch: breakdown.contentMatch,
3617
+ cooccurrenceBoost: breakdown.cooccurrenceBoost,
3618
+ typeBoost: breakdown.typeBoost,
3619
+ contextBoost: breakdown.contextBoost,
3620
+ recencyBoost: breakdown.recencyBoost,
3621
+ crossFolderBoost: breakdown.crossFolderBoost,
3622
+ hubBoost: breakdown.hubBoost,
3623
+ feedbackAdjustment: breakdown.feedbackAdjustment
3624
+ };
3625
+ if (breakdown.semanticBoost !== void 0) {
3626
+ layerMap.semanticBoost = breakdown.semanticBoost;
3627
+ }
3628
+ for (const [layer, value] of Object.entries(layerMap)) {
3629
+ acc.layers[layer] = (acc.layers[layer] ?? 0) + value;
3630
+ }
3631
+ }
3632
+ const result = [];
3633
+ for (const [bucket, acc] of buckets) {
3634
+ const avgLayers = {};
3635
+ for (const [layer, sum] of Object.entries(acc.layers)) {
3636
+ avgLayers[layer] = Math.round(sum / acc.count * 1e3) / 1e3;
3637
+ }
3638
+ result.push({ bucket, layers: avgLayers });
3639
+ }
3640
+ return result;
3641
+ }
3642
+ function getExtendedDashboardData(stateDb2) {
3643
+ const base = getDashboardData(stateDb2);
3644
+ const recentCutoff = Date.now() - 7 * 24 * 60 * 60 * 1e3;
3645
+ const eventRows = stateDb2.db.prepare(`
3646
+ SELECT breakdown_json FROM suggestion_events WHERE timestamp >= ?
3647
+ `).all(recentCutoff);
3648
+ const layerSums = {};
3649
+ const LAYER_NAMES = [
3650
+ "contentMatch",
3651
+ "cooccurrenceBoost",
3652
+ "typeBoost",
3653
+ "contextBoost",
3654
+ "recencyBoost",
3655
+ "crossFolderBoost",
3656
+ "hubBoost",
3657
+ "feedbackAdjustment",
3658
+ "semanticBoost"
3659
+ ];
3660
+ for (const name of LAYER_NAMES) {
3661
+ layerSums[name] = { sum: 0, count: 0 };
3662
+ }
3663
+ for (const row of eventRows) {
3664
+ const breakdown = JSON.parse(row.breakdown_json);
3665
+ for (const name of LAYER_NAMES) {
3666
+ const val = breakdown[name];
3667
+ if (val !== void 0) {
3668
+ layerSums[name].sum += Math.abs(val);
3669
+ layerSums[name].count++;
3670
+ }
3671
+ }
3672
+ }
3673
+ const layerHealth = LAYER_NAMES.map((layer) => {
3674
+ const s = layerSums[layer];
3675
+ const avg = s.count > 0 ? Math.round(s.sum / s.count * 1e3) / 1e3 : 0;
3676
+ let status;
3677
+ if (s.count === 0) status = "zero-data";
3678
+ else if (avg > 0) status = "contributing";
3679
+ else status = "dormant";
3680
+ return { layer, status, avgContribution: avg, eventCount: s.count };
3681
+ });
3682
+ const topEntityRows = stateDb2.db.prepare(`
3683
+ SELECT entity, COUNT(*) as cnt, AVG(total_score) as avg_score,
3684
+ SUM(CASE WHEN passed = 1 THEN 1 ELSE 0 END) * 1.0 / COUNT(*) as pass_rate
3685
+ FROM suggestion_events
3686
+ GROUP BY entity
3687
+ ORDER BY cnt DESC
3688
+ LIMIT 10
3689
+ `).all();
3690
+ const topEntities = topEntityRows.map((r) => ({
3691
+ entity: r.entity,
3692
+ suggestionCount: r.cnt,
3693
+ avgScore: Math.round(r.avg_score * 100) / 100,
3694
+ passRate: Math.round(r.pass_rate * 1e3) / 1e3
3695
+ }));
3696
+ const feedbackTrendRows = stateDb2.db.prepare(`
3697
+ SELECT date(created_at) as day, COUNT(*) as count
3698
+ FROM wikilink_feedback
3699
+ WHERE created_at >= datetime('now', '-30 days')
3700
+ GROUP BY day
3701
+ ORDER BY day
3702
+ `).all();
3703
+ const feedbackTrend = feedbackTrendRows.map((r) => ({
3704
+ day: r.day,
3705
+ count: r.count
3706
+ }));
3707
+ const suppressionRows = stateDb2.db.prepare(`
3708
+ SELECT entity, false_positive_rate, updated_at
3709
+ FROM wikilink_suppressions
3710
+ ORDER BY updated_at DESC
3711
+ `).all();
3712
+ const suppressionChanges = suppressionRows.map((r) => ({
3713
+ entity: r.entity,
3714
+ falsePositiveRate: r.false_positive_rate,
3715
+ updatedAt: r.updated_at
3716
+ }));
3717
+ return {
3718
+ ...base,
3719
+ layerHealth,
3720
+ topEntities,
3721
+ feedbackTrend,
3722
+ suppressionChanges
3723
+ };
3724
+ }
3554
3725
 
3555
3726
  // src/core/write/git.ts
3556
3727
  import { simpleGit, CheckRepoActions } from "simple-git";
@@ -3983,14 +4154,16 @@ function loadRecencyFromStateDb() {
3983
4154
  }
3984
4155
  function saveRecencyToStateDb(index) {
3985
4156
  if (!moduleStateDb3) {
3986
- console.error("[Flywheel] No StateDb available for saving recency");
4157
+ console.error("[Flywheel] saveRecencyToStateDb: No StateDb available (moduleStateDb is null)");
3987
4158
  return;
3988
4159
  }
4160
+ console.error(`[Flywheel] saveRecencyToStateDb: Saving ${index.lastMentioned.size} entries...`);
3989
4161
  try {
3990
4162
  for (const [entityNameLower, timestamp] of index.lastMentioned) {
3991
4163
  recordEntityMention(moduleStateDb3, entityNameLower, new Date(timestamp));
3992
4164
  }
3993
- console.error(`[Flywheel] Saved ${index.lastMentioned.size} recency entries to StateDb`);
4165
+ const count = moduleStateDb3.db.prepare("SELECT COUNT(*) as cnt FROM recency").get();
4166
+ console.error(`[Flywheel] Saved recency: ${index.lastMentioned.size} entries \u2192 ${count.cnt} rows in table`);
3994
4167
  } catch (e) {
3995
4168
  console.error("[Flywheel] Failed to save recency to StateDb:", e);
3996
4169
  }
@@ -4902,6 +5075,24 @@ function setWriteStateDb(stateDb2) {
4902
5075
  function getWriteStateDb() {
4903
5076
  return moduleStateDb4;
4904
5077
  }
5078
+ var moduleConfig = null;
5079
+ var ALL_IMPLICIT_PATTERNS = ["proper-nouns", "single-caps", "quoted-terms", "camel-case", "acronyms"];
5080
+ function setWikilinkConfig(config) {
5081
+ moduleConfig = config;
5082
+ }
5083
+ function getWikilinkStrictness() {
5084
+ return moduleConfig?.wikilink_strictness ?? "balanced";
5085
+ }
5086
+ function getEffectiveStrictness(notePath) {
5087
+ const base = getWikilinkStrictness();
5088
+ if (moduleConfig?.adaptive_strictness === false) return base;
5089
+ const context = notePath ? getNoteContext(notePath) : "general";
5090
+ if (context === "daily") return "aggressive";
5091
+ return base;
5092
+ }
5093
+ function getCooccurrenceIndex() {
5094
+ return cooccurrenceIndex;
5095
+ }
4905
5096
  var entityIndex = null;
4906
5097
  var indexReady = false;
4907
5098
  var indexError2 = null;
@@ -5062,9 +5253,12 @@ function processWikilinks(content, notePath) {
5062
5253
  firstOccurrenceOnly: true,
5063
5254
  caseInsensitive: true
5064
5255
  });
5256
+ const implicitEnabled = moduleConfig?.implicit_detection !== false;
5257
+ const validPatterns = new Set(ALL_IMPLICIT_PATTERNS);
5258
+ const implicitPatterns = moduleConfig?.implicit_patterns?.length ? moduleConfig.implicit_patterns.filter((p) => validPatterns.has(p)) : [...ALL_IMPLICIT_PATTERNS];
5065
5259
  const implicitMatches = detectImplicitEntities(result.content, {
5066
- detectImplicit: true,
5067
- implicitPatterns: ["proper-nouns", "single-caps", "quoted-terms", "camel-case", "acronyms"],
5260
+ detectImplicit: implicitEnabled,
5261
+ implicitPatterns,
5068
5262
  minEntityLength: 3
5069
5263
  });
5070
5264
  const alreadyLinked = new Set(
@@ -5294,7 +5488,6 @@ var STRICTNESS_CONFIGS = {
5294
5488
  // Standard bonus for exact matches
5295
5489
  }
5296
5490
  };
5297
- var DEFAULT_STRICTNESS = "conservative";
5298
5491
  var TYPE_BOOST = {
5299
5492
  people: 5,
5300
5493
  // Names are high value for connections
@@ -5481,10 +5674,12 @@ async function suggestRelatedLinks(content, options = {}) {
5481
5674
  const {
5482
5675
  maxSuggestions = 3,
5483
5676
  excludeLinked = true,
5484
- strictness = DEFAULT_STRICTNESS,
5677
+ strictness = getEffectiveStrictness(options.notePath),
5485
5678
  notePath,
5486
- detail = false
5679
+ detail = false,
5680
+ disabledLayers = []
5487
5681
  } = options;
5682
+ const disabled = new Set(disabledLayers);
5488
5683
  const config = STRICTNESS_CONFIGS[strictness];
5489
5684
  const adaptiveMinScore = getAdaptiveMinScore(content.length, config.minSuggestionScore);
5490
5685
  const noteContext = notePath ? getNoteContext(notePath) : "general";
@@ -5525,31 +5720,31 @@ async function suggestRelatedLinks(content, options = {}) {
5525
5720
  for (const { entity, category } of entitiesWithTypes) {
5526
5721
  const entityName = entity.name;
5527
5722
  if (!entityName) continue;
5528
- if (entityName.length > MAX_ENTITY_LENGTH) {
5723
+ if (!disabled.has("length_filter") && entityName.length > MAX_ENTITY_LENGTH) {
5529
5724
  continue;
5530
5725
  }
5531
- if (isLikelyArticleTitle(entityName)) {
5726
+ if (!disabled.has("article_filter") && isLikelyArticleTitle(entityName)) {
5532
5727
  continue;
5533
5728
  }
5534
5729
  if (linkedEntities.has(entityName.toLowerCase())) {
5535
5730
  continue;
5536
5731
  }
5537
- const contentScore = scoreEntity(entity, contentTokens, contentStems, config);
5732
+ const contentScore = disabled.has("exact_match") && disabled.has("stem_match") ? 0 : scoreEntity(entity, contentTokens, contentStems, config);
5538
5733
  let score = contentScore;
5539
5734
  if (contentScore > 0) {
5540
5735
  entitiesWithContentMatch.add(entityName);
5541
5736
  }
5542
- const layerTypeBoost = TYPE_BOOST[category] || 0;
5737
+ const layerTypeBoost = disabled.has("type_boost") ? 0 : TYPE_BOOST[category] || 0;
5543
5738
  score += layerTypeBoost;
5544
- const layerContextBoost = contextBoosts[category] || 0;
5739
+ const layerContextBoost = disabled.has("context_boost") ? 0 : contextBoosts[category] || 0;
5545
5740
  score += layerContextBoost;
5546
- const layerRecencyBoost = recencyIndex ? getRecencyBoost(entityName, recencyIndex) : 0;
5741
+ const layerRecencyBoost = disabled.has("recency") ? 0 : recencyIndex ? getRecencyBoost(entityName, recencyIndex) : 0;
5547
5742
  score += layerRecencyBoost;
5548
- const layerCrossFolderBoost = notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
5743
+ const layerCrossFolderBoost = disabled.has("cross_folder") ? 0 : notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
5549
5744
  score += layerCrossFolderBoost;
5550
- const layerHubBoost = getHubBoost(entity);
5745
+ const layerHubBoost = disabled.has("hub_boost") ? 0 : getHubBoost(entity);
5551
5746
  score += layerHubBoost;
5552
- const layerFeedbackAdj = feedbackBoosts.get(entityName) ?? 0;
5747
+ const layerFeedbackAdj = disabled.has("feedback") ? 0 : feedbackBoosts.get(entityName) ?? 0;
5553
5748
  score += layerFeedbackAdj;
5554
5749
  if (score > 0) {
5555
5750
  directlyMatchedEntities.add(entityName);
@@ -5573,12 +5768,12 @@ async function suggestRelatedLinks(content, options = {}) {
5573
5768
  });
5574
5769
  }
5575
5770
  }
5576
- if (cooccurrenceIndex && directlyMatchedEntities.size > 0) {
5771
+ if (!disabled.has("cooccurrence") && cooccurrenceIndex && directlyMatchedEntities.size > 0) {
5577
5772
  for (const { entity, category } of entitiesWithTypes) {
5578
5773
  const entityName = entity.name;
5579
5774
  if (!entityName) continue;
5580
- if (entityName.length > MAX_ENTITY_LENGTH) continue;
5581
- if (isLikelyArticleTitle(entityName)) continue;
5775
+ if (!disabled.has("length_filter") && entityName.length > MAX_ENTITY_LENGTH) continue;
5776
+ if (!disabled.has("article_filter") && isLikelyArticleTitle(entityName)) continue;
5582
5777
  if (linkedEntities.has(entityName.toLowerCase())) continue;
5583
5778
  const boost = getCooccurrenceBoost(entityName, directlyMatchedEntities, cooccurrenceIndex, recencyIndex);
5584
5779
  if (boost > 0) {
@@ -5595,12 +5790,12 @@ async function suggestRelatedLinks(content, options = {}) {
5595
5790
  continue;
5596
5791
  }
5597
5792
  entitiesWithContentMatch.add(entityName);
5598
- const typeBoost = TYPE_BOOST[category] || 0;
5599
- const contextBoost = contextBoosts[category] || 0;
5600
- const recencyBoostVal = recencyIndex ? getRecencyBoost(entityName, recencyIndex) : 0;
5601
- const crossFolderBoost = notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
5602
- const hubBoost = getHubBoost(entity);
5603
- const feedbackAdj = feedbackBoosts.get(entityName) ?? 0;
5793
+ const typeBoost = disabled.has("type_boost") ? 0 : TYPE_BOOST[category] || 0;
5794
+ const contextBoost = disabled.has("context_boost") ? 0 : contextBoosts[category] || 0;
5795
+ const recencyBoostVal = disabled.has("recency") ? 0 : recencyIndex ? getRecencyBoost(entityName, recencyIndex) : 0;
5796
+ const crossFolderBoost = disabled.has("cross_folder") ? 0 : notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
5797
+ const hubBoost = disabled.has("hub_boost") ? 0 : getHubBoost(entity);
5798
+ const feedbackAdj = disabled.has("feedback") ? 0 : feedbackBoosts.get(entityName) ?? 0;
5604
5799
  const totalBoost = boost + typeBoost + contextBoost + recencyBoostVal + crossFolderBoost + hubBoost + feedbackAdj;
5605
5800
  if (totalBoost >= adaptiveMinScore) {
5606
5801
  scoredEntities.push({
@@ -5624,7 +5819,7 @@ async function suggestRelatedLinks(content, options = {}) {
5624
5819
  }
5625
5820
  }
5626
5821
  }
5627
- if (content.length >= 20 && hasEntityEmbeddingsIndex()) {
5822
+ if (!disabled.has("semantic") && content.length >= 20 && hasEntityEmbeddingsIndex()) {
5628
5823
  try {
5629
5824
  const contentEmbedding = await embedTextCached(content);
5630
5825
  const alreadyScoredNames = new Set(scoredEntities.map((e) => e.name));
@@ -5646,14 +5841,14 @@ async function suggestRelatedLinks(content, options = {}) {
5646
5841
  (et) => et.entity.name === match.entityName
5647
5842
  );
5648
5843
  if (!entityWithType) continue;
5649
- if (match.entityName.length > MAX_ENTITY_LENGTH) continue;
5650
- if (isLikelyArticleTitle(match.entityName)) continue;
5844
+ if (!disabled.has("length_filter") && match.entityName.length > MAX_ENTITY_LENGTH) continue;
5845
+ if (!disabled.has("article_filter") && isLikelyArticleTitle(match.entityName)) continue;
5651
5846
  const { entity, category } = entityWithType;
5652
- const layerTypeBoost = TYPE_BOOST[category] || 0;
5653
- const layerContextBoost = contextBoosts[category] || 0;
5654
- const layerHubBoost = getHubBoost(entity);
5655
- const layerCrossFolderBoost = notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
5656
- const layerFeedbackAdj = feedbackBoosts.get(match.entityName) ?? 0;
5847
+ const layerTypeBoost = disabled.has("type_boost") ? 0 : TYPE_BOOST[category] || 0;
5848
+ const layerContextBoost = disabled.has("context_boost") ? 0 : contextBoosts[category] || 0;
5849
+ const layerHubBoost = disabled.has("hub_boost") ? 0 : getHubBoost(entity);
5850
+ const layerCrossFolderBoost = disabled.has("cross_folder") ? 0 : notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
5851
+ const layerFeedbackAdj = disabled.has("feedback") ? 0 : feedbackBoosts.get(match.entityName) ?? 0;
5657
5852
  const totalScore = boost + layerTypeBoost + layerContextBoost + layerHubBoost + layerCrossFolderBoost + layerFeedbackAdj;
5658
5853
  if (totalScore >= adaptiveMinScore) {
5659
5854
  scoredEntities.push({
@@ -5695,7 +5890,51 @@ async function suggestRelatedLinks(content, options = {}) {
5695
5890
  }
5696
5891
  return 0;
5697
5892
  });
5698
- const topEntries = relevantEntities.slice(0, maxSuggestions);
5893
+ if (moduleStateDb4 && notePath) {
5894
+ try {
5895
+ const now = Date.now();
5896
+ const insertStmt = moduleStateDb4.db.prepare(`
5897
+ INSERT OR IGNORE INTO suggestion_events
5898
+ (timestamp, note_path, entity, total_score, breakdown_json, threshold, passed, strictness, applied, pipeline_event_id)
5899
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, NULL)
5900
+ `);
5901
+ const persistTransaction = moduleStateDb4.db.transaction(() => {
5902
+ for (const e of relevantEntities) {
5903
+ insertStmt.run(
5904
+ now,
5905
+ notePath,
5906
+ e.name,
5907
+ e.score,
5908
+ JSON.stringify(e.breakdown),
5909
+ adaptiveMinScore,
5910
+ 1,
5911
+ // passed threshold (these are relevantEntities)
5912
+ strictness
5913
+ );
5914
+ }
5915
+ for (const e of scoredEntities) {
5916
+ if (!entitiesWithContentMatch.has(e.name)) continue;
5917
+ if (relevantEntities.some((r) => r.name === e.name)) continue;
5918
+ insertStmt.run(
5919
+ now,
5920
+ notePath,
5921
+ e.name,
5922
+ e.score,
5923
+ JSON.stringify(e.breakdown),
5924
+ adaptiveMinScore,
5925
+ 0,
5926
+ // did not pass threshold
5927
+ strictness
5928
+ );
5929
+ }
5930
+ });
5931
+ persistTransaction();
5932
+ } catch {
5933
+ }
5934
+ }
5935
+ const currentNoteStem = notePath ? notePath.replace(/\.md$/, "").split("/").pop()?.toLowerCase() : null;
5936
+ const filtered = currentNoteStem ? relevantEntities.filter((e) => e.name.toLowerCase() !== currentNoteStem) : relevantEntities;
5937
+ const topEntries = filtered.slice(0, maxSuggestions);
5699
5938
  const topSuggestions = topEntries.map((e) => e.name);
5700
5939
  if (topSuggestions.length === 0) {
5701
5940
  return emptyResult;
@@ -6046,6 +6285,17 @@ function searchFTS5(_vaultPath, query, limit = 10) {
6046
6285
  function getFTS5State() {
6047
6286
  return { ...state };
6048
6287
  }
6288
+ function countFTS5Mentions(term) {
6289
+ if (!db2) return 0;
6290
+ try {
6291
+ const result = db2.prepare(
6292
+ "SELECT COUNT(*) as cnt FROM notes_fts WHERE content MATCH ?"
6293
+ ).get(`"${term}"`);
6294
+ return result?.cnt ?? 0;
6295
+ } catch {
6296
+ return 0;
6297
+ }
6298
+ }
6049
6299
 
6050
6300
  // src/core/read/taskCache.ts
6051
6301
  import * as path10 from "path";
@@ -7057,12 +7307,13 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
7057
7307
  inputSchema: {
7058
7308
  path: z2.string().optional().describe("Path to a specific note to validate. If omitted, validates all notes."),
7059
7309
  typos_only: z2.boolean().default(false).describe("If true, only report broken links that have a similar existing note (likely typos)"),
7310
+ 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
7311
  limit: z2.coerce.number().default(50).describe("Maximum number of broken links to return"),
7061
7312
  offset: z2.coerce.number().default(0).describe("Number of broken links to skip (for pagination)")
7062
7313
  },
7063
7314
  outputSchema: ValidateLinksOutputSchema
7064
7315
  },
7065
- async ({ path: notePath, typos_only, limit: requestedLimit, offset }) => {
7316
+ async ({ path: notePath, typos_only, group_by_target, limit: requestedLimit, offset }) => {
7066
7317
  const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
7067
7318
  const index = getIndex();
7068
7319
  const allBroken = [];
@@ -7103,6 +7354,41 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
7103
7354
  }
7104
7355
  }
7105
7356
  }
7357
+ if (group_by_target) {
7358
+ const targetMap = /* @__PURE__ */ new Map();
7359
+ for (const broken2 of allBroken) {
7360
+ const key = broken2.target.toLowerCase();
7361
+ const existing = targetMap.get(key);
7362
+ if (existing) {
7363
+ existing.count++;
7364
+ if (existing.sources.size < 5) existing.sources.add(broken2.source);
7365
+ if (!existing.suggestion && broken2.suggestion) existing.suggestion = broken2.suggestion;
7366
+ } else {
7367
+ targetMap.set(key, {
7368
+ count: 1,
7369
+ sources: /* @__PURE__ */ new Set([broken2.source]),
7370
+ suggestion: broken2.suggestion
7371
+ });
7372
+ }
7373
+ }
7374
+ const targets = Array.from(targetMap.entries()).map(([target, data]) => ({
7375
+ target,
7376
+ mention_count: data.count,
7377
+ sources: Array.from(data.sources),
7378
+ ...data.suggestion ? { suggestion: data.suggestion } : {}
7379
+ })).sort((a, b) => b.mention_count - a.mention_count).slice(offset, offset + limit);
7380
+ const grouped = {
7381
+ scope: notePath || "all",
7382
+ total_dead_targets: targetMap.size,
7383
+ total_broken_links: allBroken.length,
7384
+ returned_count: targets.length,
7385
+ targets
7386
+ };
7387
+ return {
7388
+ content: [{ type: "text", text: JSON.stringify(grouped, null, 2) }],
7389
+ structuredContent: grouped
7390
+ };
7391
+ }
7106
7392
  const broken = allBroken.slice(offset, offset + limit);
7107
7393
  const output = {
7108
7394
  scope: notePath || "all",
@@ -7123,6 +7409,106 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
7123
7409
  };
7124
7410
  }
7125
7411
  );
7412
+ server2.registerTool(
7413
+ "discover_stub_candidates",
7414
+ {
7415
+ title: "Discover Stub Candidates",
7416
+ 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.`,
7417
+ inputSchema: {
7418
+ min_frequency: z2.coerce.number().default(2).describe("Minimum number of references to include (default 2)"),
7419
+ limit: z2.coerce.number().default(20).describe("Maximum candidates to return (default 20)")
7420
+ }
7421
+ },
7422
+ async ({ min_frequency, limit: requestedLimit }) => {
7423
+ const index = getIndex();
7424
+ const limit = Math.min(requestedLimit ?? 20, 100);
7425
+ const minFreq = min_frequency ?? 2;
7426
+ const targetMap = /* @__PURE__ */ new Map();
7427
+ for (const note of index.notes.values()) {
7428
+ for (const link of note.outlinks) {
7429
+ if (!resolveTarget(index, link.target)) {
7430
+ const key = link.target.toLowerCase();
7431
+ const existing = targetMap.get(key);
7432
+ if (existing) {
7433
+ existing.count++;
7434
+ if (existing.sources.size < 3) existing.sources.add(note.path);
7435
+ } else {
7436
+ targetMap.set(key, { count: 1, sources: /* @__PURE__ */ new Set([note.path]) });
7437
+ }
7438
+ }
7439
+ }
7440
+ }
7441
+ const candidates = Array.from(targetMap.entries()).filter(([, data]) => data.count >= minFreq).map(([target, data]) => {
7442
+ const fts5Mentions = countFTS5Mentions(target);
7443
+ return {
7444
+ term: target,
7445
+ wikilink_references: data.count,
7446
+ content_mentions: fts5Mentions,
7447
+ sample_notes: Array.from(data.sources)
7448
+ };
7449
+ }).sort((a, b) => b.wikilink_references - a.wikilink_references).slice(0, limit);
7450
+ const output = {
7451
+ total_dead_targets: targetMap.size,
7452
+ candidates_above_threshold: candidates.length,
7453
+ candidates
7454
+ };
7455
+ return {
7456
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }]
7457
+ };
7458
+ }
7459
+ );
7460
+ server2.registerTool(
7461
+ "discover_cooccurrence_gaps",
7462
+ {
7463
+ title: "Discover Co-occurrence Gaps",
7464
+ 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.",
7465
+ inputSchema: {
7466
+ min_cooccurrence: z2.coerce.number().default(3).describe("Minimum co-occurrence count to include (default 3)"),
7467
+ limit: z2.coerce.number().default(20).describe("Maximum gaps to return (default 20)")
7468
+ }
7469
+ },
7470
+ async ({ min_cooccurrence, limit: requestedLimit }) => {
7471
+ const index = getIndex();
7472
+ const coocIndex = getCooccurrenceIndex();
7473
+ const limit = Math.min(requestedLimit ?? 20, 100);
7474
+ const minCount = min_cooccurrence ?? 3;
7475
+ if (!coocIndex) {
7476
+ return {
7477
+ content: [{ type: "text", text: JSON.stringify({ error: "Co-occurrence index not built yet. Wait for entity index initialization." }) }]
7478
+ };
7479
+ }
7480
+ const gaps = [];
7481
+ const seenPairs = /* @__PURE__ */ new Set();
7482
+ for (const [entityA, associations] of Object.entries(coocIndex.associations)) {
7483
+ for (const [entityB, count] of associations) {
7484
+ if (count < minCount) continue;
7485
+ const pairKey = [entityA, entityB].sort().join("||");
7486
+ if (seenPairs.has(pairKey)) continue;
7487
+ seenPairs.add(pairKey);
7488
+ const aHasNote = resolveTarget(index, entityA) !== null;
7489
+ const bHasNote = resolveTarget(index, entityB) !== null;
7490
+ if (aHasNote && bHasNote) continue;
7491
+ gaps.push({
7492
+ entity_a: entityA,
7493
+ entity_b: entityB,
7494
+ cooccurrence_count: count,
7495
+ a_has_note: aHasNote,
7496
+ b_has_note: bHasNote
7497
+ });
7498
+ }
7499
+ }
7500
+ gaps.sort((a, b) => b.cooccurrence_count - a.cooccurrence_count);
7501
+ const top = gaps.slice(0, limit);
7502
+ const output = {
7503
+ total_gaps: gaps.length,
7504
+ returned_count: top.length,
7505
+ gaps: top
7506
+ };
7507
+ return {
7508
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }]
7509
+ };
7510
+ }
7511
+ );
7126
7512
  }
7127
7513
 
7128
7514
  // src/tools/read/health.ts
@@ -7488,6 +7874,93 @@ function purgeOldIndexEvents(stateDb2, retentionDays = 90) {
7488
7874
  return result.changes;
7489
7875
  }
7490
7876
 
7877
+ // src/core/read/sweep.ts
7878
+ var DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1e3;
7879
+ var MIN_SWEEP_INTERVAL_MS = 30 * 1e3;
7880
+ var cachedResults = null;
7881
+ var sweepTimer = null;
7882
+ var sweepRunning = false;
7883
+ function runSweep(index) {
7884
+ const start = Date.now();
7885
+ let deadLinkCount = 0;
7886
+ const deadTargetCounts = /* @__PURE__ */ new Map();
7887
+ for (const note of index.notes.values()) {
7888
+ for (const link of note.outlinks) {
7889
+ if (!resolveTarget(index, link.target)) {
7890
+ deadLinkCount++;
7891
+ const key = link.target.toLowerCase();
7892
+ deadTargetCounts.set(key, (deadTargetCounts.get(key) || 0) + 1);
7893
+ }
7894
+ }
7895
+ }
7896
+ const topDeadTargets = Array.from(deadTargetCounts.entries()).filter(([, count]) => count >= 2).map(([target, wikilink_references]) => ({
7897
+ target,
7898
+ wikilink_references,
7899
+ content_mentions: countFTS5Mentions(target)
7900
+ })).sort((a, b) => b.wikilink_references - a.wikilink_references).slice(0, 10);
7901
+ const linkedCounts = /* @__PURE__ */ new Map();
7902
+ for (const note of index.notes.values()) {
7903
+ for (const link of note.outlinks) {
7904
+ const key = link.target.toLowerCase();
7905
+ linkedCounts.set(key, (linkedCounts.get(key) || 0) + 1);
7906
+ }
7907
+ }
7908
+ const seen = /* @__PURE__ */ new Set();
7909
+ const unlinkedEntities = [];
7910
+ for (const [name, entityPath] of index.entities) {
7911
+ if (seen.has(entityPath)) continue;
7912
+ seen.add(entityPath);
7913
+ const totalMentions = countFTS5Mentions(name);
7914
+ if (totalMentions === 0) continue;
7915
+ const pathKey = entityPath.toLowerCase().replace(/\.md$/, "");
7916
+ const linked = Math.max(linkedCounts.get(name) || 0, linkedCounts.get(pathKey) || 0);
7917
+ const unlinked = Math.max(0, totalMentions - linked - 1);
7918
+ if (unlinked <= 0) continue;
7919
+ const note = index.notes.get(entityPath);
7920
+ const displayName = note?.title || name;
7921
+ unlinkedEntities.push({ entity: displayName, path: entityPath, unlinked_mentions: unlinked });
7922
+ }
7923
+ unlinkedEntities.sort((a, b) => b.unlinked_mentions - a.unlinked_mentions);
7924
+ const results = {
7925
+ last_sweep_at: Date.now(),
7926
+ sweep_duration_ms: Date.now() - start,
7927
+ dead_link_count: deadLinkCount,
7928
+ top_dead_targets: topDeadTargets,
7929
+ top_unlinked_entities: unlinkedEntities.slice(0, 10)
7930
+ };
7931
+ cachedResults = results;
7932
+ return results;
7933
+ }
7934
+ function startSweepTimer(getIndex, intervalMs) {
7935
+ const interval = Math.max(intervalMs ?? DEFAULT_SWEEP_INTERVAL_MS, MIN_SWEEP_INTERVAL_MS);
7936
+ setTimeout(() => {
7937
+ doSweep(getIndex);
7938
+ }, 5e3);
7939
+ sweepTimer = setInterval(() => {
7940
+ doSweep(getIndex);
7941
+ }, interval);
7942
+ if (sweepTimer && typeof sweepTimer === "object" && "unref" in sweepTimer) {
7943
+ sweepTimer.unref();
7944
+ }
7945
+ }
7946
+ function doSweep(getIndex) {
7947
+ if (sweepRunning) return;
7948
+ sweepRunning = true;
7949
+ try {
7950
+ const index = getIndex();
7951
+ if (index && index.notes && index.notes.size > 0) {
7952
+ runSweep(index);
7953
+ }
7954
+ } catch (err) {
7955
+ console.error("[Flywheel] Sweep error:", err);
7956
+ } finally {
7957
+ sweepRunning = false;
7958
+ }
7959
+ }
7960
+ function getSweepResults() {
7961
+ return cachedResults;
7962
+ }
7963
+
7491
7964
  // src/tools/read/health.ts
7492
7965
  var STALE_THRESHOLD_SECONDS = 300;
7493
7966
  function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () => ({}), getStateDb = () => null) {
@@ -7563,6 +8036,26 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
7563
8036
  embeddings_count: z3.coerce.number().describe("Number of notes with semantic embeddings"),
7564
8037
  tasks_ready: z3.boolean().describe("Whether the task cache is ready to serve queries"),
7565
8038
  tasks_building: z3.boolean().describe("Whether the task cache is currently rebuilding"),
8039
+ dead_link_count: z3.coerce.number().describe("Total number of broken/dead wikilinks across the vault"),
8040
+ top_dead_link_targets: z3.array(z3.object({
8041
+ target: z3.string().describe("The dead link target"),
8042
+ mention_count: z3.coerce.number().describe("How many notes reference this dead target")
8043
+ })).describe("Top 5 most-referenced dead link targets (highest-ROI candidates to create)"),
8044
+ sweep: z3.object({
8045
+ last_sweep_at: z3.number().describe("When the last background sweep completed (ms epoch)"),
8046
+ sweep_duration_ms: z3.number().describe("How long the last sweep took"),
8047
+ dead_link_count: z3.number().describe("Dead links found by sweep"),
8048
+ top_dead_targets: z3.array(z3.object({
8049
+ target: z3.string(),
8050
+ wikilink_references: z3.number(),
8051
+ content_mentions: z3.number()
8052
+ })).describe("Top dead link targets with FTS5 content mention counts"),
8053
+ top_unlinked_entities: z3.array(z3.object({
8054
+ entity: z3.string(),
8055
+ path: z3.string(),
8056
+ unlinked_mentions: z3.number()
8057
+ })).describe("Entities with the most unlinked plain-text mentions")
8058
+ }).optional().describe("Background sweep results (graph hygiene metrics, updated every 5 min)"),
7566
8059
  recommendations: z3.array(z3.string()).describe("Suggested actions if any issues detected")
7567
8060
  };
7568
8061
  server2.registerTool(
@@ -7685,6 +8178,20 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
7685
8178
  }
7686
8179
  }
7687
8180
  const ftsState = getFTS5State();
8181
+ let deadLinkCount = 0;
8182
+ const deadTargetCounts = /* @__PURE__ */ new Map();
8183
+ if (indexBuilt) {
8184
+ for (const note of index.notes.values()) {
8185
+ for (const link of note.outlinks) {
8186
+ if (!resolveTarget(index, link.target)) {
8187
+ deadLinkCount++;
8188
+ const key = link.target.toLowerCase();
8189
+ deadTargetCounts.set(key, (deadTargetCounts.get(key) || 0) + 1);
8190
+ }
8191
+ }
8192
+ }
8193
+ }
8194
+ 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
8195
  const output = {
7689
8196
  status,
7690
8197
  schema_version: SCHEMA_VERSION,
@@ -7712,6 +8219,9 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
7712
8219
  embeddings_count: getEmbeddingsCount(),
7713
8220
  tasks_ready: isTaskCacheReady(),
7714
8221
  tasks_building: isTaskCacheBuilding(),
8222
+ dead_link_count: deadLinkCount,
8223
+ top_dead_link_targets: topDeadLinkTargets,
8224
+ sweep: getSweepResults() ?? void 0,
7715
8225
  recommendations
7716
8226
  };
7717
8227
  return {
@@ -8205,6 +8715,14 @@ function generateAliasCandidates(entityName, existingAliases) {
8205
8715
  candidates.push({ candidate: short, type: "short_form" });
8206
8716
  }
8207
8717
  }
8718
+ const STOPWORDS2 = /* @__PURE__ */ new Set(["the", "and", "for", "with", "from", "into", "that", "this", "are", "was", "has", "its"]);
8719
+ for (const word of words) {
8720
+ if (word.length < 4) continue;
8721
+ if (STOPWORDS2.has(word.toLowerCase())) continue;
8722
+ if (existing.has(word.toLowerCase())) continue;
8723
+ if (words.length >= 3 && word === words[0]) continue;
8724
+ candidates.push({ candidate: word, type: "name_fragment" });
8725
+ }
8208
8726
  }
8209
8727
  return candidates;
8210
8728
  }
@@ -8231,6 +8749,7 @@ function suggestEntityAliases(stateDb2, folder) {
8231
8749
  mentions = result?.cnt ?? 0;
8232
8750
  } catch {
8233
8751
  }
8752
+ if (type === "name_fragment" && mentions < 5) continue;
8234
8753
  suggestions.push({
8235
8754
  entity: row.name,
8236
8755
  entity_path: row.path,
@@ -8761,6 +9280,61 @@ function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfi
8761
9280
  };
8762
9281
  }
8763
9282
  );
9283
+ server2.registerTool(
9284
+ "unlinked_mentions_report",
9285
+ {
9286
+ title: "Unlinked Mentions Report",
9287
+ 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.",
9288
+ inputSchema: {
9289
+ limit: z5.coerce.number().default(20).describe("Maximum entities to return (default 20)")
9290
+ }
9291
+ },
9292
+ async ({ limit: requestedLimit }) => {
9293
+ requireIndex();
9294
+ const index = getIndex();
9295
+ const limit = Math.min(requestedLimit ?? 20, 100);
9296
+ const linkedCounts = /* @__PURE__ */ new Map();
9297
+ for (const note of index.notes.values()) {
9298
+ for (const link of note.outlinks) {
9299
+ const key = link.target.toLowerCase();
9300
+ linkedCounts.set(key, (linkedCounts.get(key) || 0) + 1);
9301
+ }
9302
+ }
9303
+ const results = [];
9304
+ const seen = /* @__PURE__ */ new Set();
9305
+ for (const [name, entityPath] of index.entities) {
9306
+ if (seen.has(entityPath)) continue;
9307
+ seen.add(entityPath);
9308
+ const totalMentions = countFTS5Mentions(name);
9309
+ if (totalMentions === 0) continue;
9310
+ const pathKey = entityPath.toLowerCase().replace(/\.md$/, "");
9311
+ const linkedByName = linkedCounts.get(name) || 0;
9312
+ const linkedByPath = linkedCounts.get(pathKey) || 0;
9313
+ const linked = Math.max(linkedByName, linkedByPath);
9314
+ const unlinked = Math.max(0, totalMentions - linked - 1);
9315
+ if (unlinked <= 0) continue;
9316
+ const note = index.notes.get(entityPath);
9317
+ const displayName = note?.title || name;
9318
+ results.push({
9319
+ entity: displayName,
9320
+ path: entityPath,
9321
+ total_mentions: totalMentions,
9322
+ linked_mentions: linked,
9323
+ unlinked_mentions: unlinked
9324
+ });
9325
+ }
9326
+ results.sort((a, b) => b.unlinked_mentions - a.unlinked_mentions);
9327
+ const top = results.slice(0, limit);
9328
+ const output = {
9329
+ total_entities_checked: seen.size,
9330
+ entities_with_unlinked: results.length,
9331
+ top_entities: top
9332
+ };
9333
+ return {
9334
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }]
9335
+ };
9336
+ }
9337
+ );
8764
9338
  }
8765
9339
 
8766
9340
  // src/tools/read/primitives.ts
@@ -9938,6 +10512,49 @@ function getEmergingHubs(stateDb2, daysBack = 30) {
9938
10512
  emerging.sort((a, b) => b.growth - a.growth);
9939
10513
  return emerging;
9940
10514
  }
10515
+ function compareGraphSnapshots(stateDb2, timestampBefore, timestampAfter) {
10516
+ const SCALAR_METRICS = ["avg_degree", "max_degree", "cluster_count", "largest_cluster_size"];
10517
+ function getSnapshotAt(ts) {
10518
+ const row = stateDb2.db.prepare(
10519
+ `SELECT DISTINCT timestamp FROM graph_snapshots WHERE timestamp <= ? ORDER BY timestamp DESC LIMIT 1`
10520
+ ).get(ts);
10521
+ if (!row) return null;
10522
+ const rows = stateDb2.db.prepare(
10523
+ `SELECT metric, value, details FROM graph_snapshots WHERE timestamp = ?`
10524
+ ).all(row.timestamp);
10525
+ return rows;
10526
+ }
10527
+ const beforeRows = getSnapshotAt(timestampBefore) ?? [];
10528
+ const afterRows = getSnapshotAt(timestampAfter) ?? [];
10529
+ const beforeMap = /* @__PURE__ */ new Map();
10530
+ const afterMap = /* @__PURE__ */ new Map();
10531
+ for (const r of beforeRows) beforeMap.set(r.metric, { value: r.value, details: r.details });
10532
+ for (const r of afterRows) afterMap.set(r.metric, { value: r.value, details: r.details });
10533
+ const metricChanges = SCALAR_METRICS.map((metric) => {
10534
+ const before = beforeMap.get(metric)?.value ?? 0;
10535
+ const after = afterMap.get(metric)?.value ?? 0;
10536
+ const delta = after - before;
10537
+ const deltaPercent = before !== 0 ? Math.round(delta / before * 1e4) / 100 : delta !== 0 ? 100 : 0;
10538
+ return { metric, before, after, delta, deltaPercent };
10539
+ });
10540
+ const beforeHubs = beforeMap.get("hub_scores_top10")?.details ? JSON.parse(beforeMap.get("hub_scores_top10").details) : [];
10541
+ const afterHubs = afterMap.get("hub_scores_top10")?.details ? JSON.parse(afterMap.get("hub_scores_top10").details) : [];
10542
+ const beforeHubMap = /* @__PURE__ */ new Map();
10543
+ for (const h of beforeHubs) beforeHubMap.set(h.entity, h.degree);
10544
+ const afterHubMap = /* @__PURE__ */ new Map();
10545
+ for (const h of afterHubs) afterHubMap.set(h.entity, h.degree);
10546
+ const allHubEntities = /* @__PURE__ */ new Set([...beforeHubMap.keys(), ...afterHubMap.keys()]);
10547
+ const hubScoreChanges = [];
10548
+ for (const entity of allHubEntities) {
10549
+ const before = beforeHubMap.get(entity) ?? 0;
10550
+ const after = afterHubMap.get(entity) ?? 0;
10551
+ if (before !== after) {
10552
+ hubScoreChanges.push({ entity, before, after, delta: after - before });
10553
+ }
10554
+ }
10555
+ hubScoreChanges.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
10556
+ return { metricChanges, hubScoreChanges };
10557
+ }
9941
10558
  function purgeOldSnapshots(stateDb2, retentionDays = 90) {
9942
10559
  const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1e3;
9943
10560
  const result = stateDb2.db.prepare(
@@ -11609,10 +12226,11 @@ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", con
11609
12226
  validate: z11.boolean().default(true).describe("Check input for common issues (double timestamps, non-markdown bullets, etc.)"),
11610
12227
  normalize: z11.boolean().default(true).describe("Auto-fix common issues before formatting (replace \u2022 with -, trim excessive whitespace, etc.)"),
11611
12228
  guardrails: z11.enum(["warn", "strict", "off"]).default("warn").describe('Output validation mode: "warn" returns issues but proceeds, "strict" blocks on errors, "off" disables'),
12229
+ linkedEntities: z11.array(z11.string()).optional().describe("Entity names already linked in the content. When skipWikilinks=true, these are tracked for feedback without re-processing the content."),
11612
12230
  agent_id: z11.string().optional().describe('Agent identifier for multi-agent scoping (e.g., "claude-opus", "planning-agent")'),
11613
12231
  session_id: z11.string().optional().describe('Session identifier for conversation scoping (e.g., "sess-abc123")')
11614
12232
  },
11615
- async ({ path: notePath, section, content, create_if_missing, position, format, commit, skipWikilinks, preserveListNesting, bumpHeadings, suggestOutgoingLinks, maxSuggestions, validate, normalize, guardrails, agent_id, session_id }) => {
12233
+ async ({ path: notePath, section, content, create_if_missing, position, format, commit, skipWikilinks, preserveListNesting, bumpHeadings, suggestOutgoingLinks, maxSuggestions, validate, normalize, guardrails, linkedEntities, agent_id, session_id }) => {
11616
12234
  let noteCreated = false;
11617
12235
  let templateUsed;
11618
12236
  if (create_if_missing) {
@@ -11647,6 +12265,12 @@ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", con
11647
12265
  }
11648
12266
  let workingContent = validationResult.content;
11649
12267
  let { content: processedContent, wikilinkInfo } = maybeApplyWikilinks(workingContent, skipWikilinks, notePath);
12268
+ if (linkedEntities?.length) {
12269
+ const stateDb2 = getWriteStateDb();
12270
+ if (stateDb2) {
12271
+ trackWikilinkApplications(stateDb2, notePath, linkedEntities);
12272
+ }
12273
+ }
11650
12274
  const _debug = {
11651
12275
  entityCount: getEntityIndexStats().totalEntities,
11652
12276
  indexReady: getEntityIndexStats().ready,
@@ -12082,7 +12706,7 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
12082
12706
  overwrite: z14.boolean().default(false).describe("If true, overwrite existing file"),
12083
12707
  commit: z14.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
12084
12708
  skipWikilinks: z14.boolean().default(false).describe("If true, skip auto-wikilink application (wikilinks are applied by default)"),
12085
- suggestOutgoingLinks: z14.boolean().default(false).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]], [[Philosophy]]"). Disabled by default for templates; enable for content-rich notes.'),
12709
+ suggestOutgoingLinks: z14.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]], [[Philosophy]]").'),
12086
12710
  maxSuggestions: z14.number().min(1).max(10).default(3).describe("Maximum number of suggested wikilinks to append (1-10, default: 3)"),
12087
12711
  agent_id: z14.string().optional().describe("Agent identifier for multi-agent scoping"),
12088
12712
  session_id: z14.string().optional().describe("Session identifier for conversation scoping")
@@ -12119,8 +12743,12 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
12119
12743
  return formatMcpResult(errorResult(notePath, `Template not found: ${template}`));
12120
12744
  }
12121
12745
  }
12746
+ const now = /* @__PURE__ */ new Date();
12122
12747
  if (!effectiveFrontmatter.date) {
12123
- effectiveFrontmatter.date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
12748
+ effectiveFrontmatter.date = now.toISOString().split("T")[0];
12749
+ }
12750
+ if (!effectiveFrontmatter.created) {
12751
+ effectiveFrontmatter.created = now.toISOString();
12124
12752
  }
12125
12753
  const warnings = [];
12126
12754
  const noteName = path21.basename(notePath, ".md");
@@ -13095,12 +13723,17 @@ async function executeStep(step, vaultPath2, context, conditionResults) {
13095
13723
  const resolvedParams = interpolateObject(step.params, context);
13096
13724
  try {
13097
13725
  const result = await executeToolCall(step.tool, resolvedParams, vaultPath2, context);
13726
+ const outputs = {};
13727
+ if (result.path) {
13728
+ outputs.path = result.path;
13729
+ }
13098
13730
  return {
13099
13731
  stepId: step.id,
13100
13732
  success: result.success,
13101
13733
  message: result.message,
13102
13734
  path: result.path,
13103
- preview: result.preview
13735
+ preview: result.preview,
13736
+ outputs
13104
13737
  };
13105
13738
  } catch (error) {
13106
13739
  return {
@@ -13491,6 +14124,9 @@ async function executePolicy(policy, vaultPath2, variables, commit = false) {
13491
14124
  if (result.path && result.success && !result.skipped) {
13492
14125
  filesModified.add(result.path);
13493
14126
  }
14127
+ if (result.success && !result.skipped && result.outputs) {
14128
+ context.steps[step.id] = result.outputs;
14129
+ }
13494
14130
  if (!result.success && !result.skipped) {
13495
14131
  if (commit && filesModified.size > 0) {
13496
14132
  await rollbackChanges(vaultPath2, originalContents, filesModified);
@@ -14451,21 +15087,26 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
14451
15087
  "wikilink_feedback",
14452
15088
  {
14453
15089
  title: "Wikilink Feedback",
14454
- description: 'Report and query wikilink accuracy feedback. Modes: "report" (record feedback), "list" (view recent feedback), "stats" (entity accuracy statistics), "dashboard" (full feedback loop data for visualization). Entities with >=30% false positive rate (and >=10 samples) are auto-suppressed from future wikilink application.',
15090
+ description: 'Report and query wikilink accuracy feedback. Modes: "report" (record feedback), "list" (view recent feedback), "stats" (entity accuracy statistics), "dashboard" (full feedback loop data), "entity_timeline" (score history for an entity), "layer_timeseries" (per-layer contribution over time), "snapshot_diff" (compare two graph snapshots).',
14455
15091
  inputSchema: {
14456
- mode: z21.enum(["report", "list", "stats", "dashboard"]).describe("Operation mode"),
14457
- entity: z21.string().optional().describe("Entity name (required for report mode, optional filter for list/stats)"),
15092
+ mode: z21.enum(["report", "list", "stats", "dashboard", "entity_timeline", "layer_timeseries", "snapshot_diff"]).describe("Operation mode"),
15093
+ entity: z21.string().optional().describe("Entity name (required for report and entity_timeline modes, optional filter for list/stats)"),
14458
15094
  note_path: z21.string().optional().describe("Note path where the wikilink appeared (for report mode)"),
14459
15095
  context: z21.string().optional().describe("Surrounding text context (for report mode)"),
14460
15096
  correct: z21.boolean().optional().describe("Whether the wikilink was correct (for report mode)"),
14461
- limit: z21.number().optional().describe("Max entries to return for list mode (default: 20)")
15097
+ limit: z21.number().optional().describe("Max entries to return (default: 20 for list, 100 for entity_timeline)"),
15098
+ days_back: z21.number().optional().describe("Days to look back (default: 30)"),
15099
+ granularity: z21.enum(["day", "week"]).optional().describe("Time bucket granularity for layer_timeseries (default: day)"),
15100
+ timestamp_before: z21.number().optional().describe("Earlier timestamp for snapshot_diff"),
15101
+ timestamp_after: z21.number().optional().describe("Later timestamp for snapshot_diff")
14462
15102
  }
14463
15103
  },
14464
- async ({ mode, entity, note_path, context, correct, limit }) => {
15104
+ async ({ mode, entity, note_path, context, correct, limit, days_back, granularity, timestamp_before, timestamp_after }) => {
14465
15105
  const stateDb2 = getStateDb();
14466
15106
  if (!stateDb2) {
14467
15107
  return {
14468
- content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available" }) }]
15108
+ content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available \u2014 database not initialized yet" }) }],
15109
+ isError: true
14469
15110
  };
14470
15111
  }
14471
15112
  let result;
@@ -14476,7 +15117,15 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
14476
15117
  content: [{ type: "text", text: JSON.stringify({ error: "entity and correct are required for report mode" }) }]
14477
15118
  };
14478
15119
  }
14479
- recordFeedback(stateDb2, entity, context || "", note_path || "", correct);
15120
+ try {
15121
+ recordFeedback(stateDb2, entity, context || "", note_path || "", correct);
15122
+ } catch (e) {
15123
+ return {
15124
+ content: [{ type: "text", text: JSON.stringify({
15125
+ error: `Failed to record feedback: ${e instanceof Error ? e.message : String(e)}`
15126
+ }) }]
15127
+ };
15128
+ }
14480
15129
  const suppressionUpdated = updateSuppressionList(stateDb2) > 0;
14481
15130
  result = {
14482
15131
  mode: "report",
@@ -14509,7 +15158,7 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
14509
15158
  break;
14510
15159
  }
14511
15160
  case "dashboard": {
14512
- const dashboard = getDashboardData(stateDb2);
15161
+ const dashboard = getExtendedDashboardData(stateDb2);
14513
15162
  result = {
14514
15163
  mode: "dashboard",
14515
15164
  dashboard,
@@ -14518,6 +15167,44 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
14518
15167
  };
14519
15168
  break;
14520
15169
  }
15170
+ case "entity_timeline": {
15171
+ if (!entity) {
15172
+ return {
15173
+ content: [{ type: "text", text: JSON.stringify({ error: "entity is required for entity_timeline mode" }) }]
15174
+ };
15175
+ }
15176
+ const timeline = getEntityScoreTimeline(stateDb2, entity, days_back ?? 30, limit ?? 100);
15177
+ result = {
15178
+ mode: "entity_timeline",
15179
+ entity,
15180
+ timeline,
15181
+ count: timeline.length
15182
+ };
15183
+ break;
15184
+ }
15185
+ case "layer_timeseries": {
15186
+ const timeseries = getLayerContributionTimeseries(stateDb2, granularity ?? "day", days_back ?? 30);
15187
+ result = {
15188
+ mode: "layer_timeseries",
15189
+ granularity: granularity ?? "day",
15190
+ timeseries,
15191
+ buckets: timeseries.length
15192
+ };
15193
+ break;
15194
+ }
15195
+ case "snapshot_diff": {
15196
+ if (!timestamp_before || !timestamp_after) {
15197
+ return {
15198
+ content: [{ type: "text", text: JSON.stringify({ error: "timestamp_before and timestamp_after are required for snapshot_diff mode" }) }]
15199
+ };
15200
+ }
15201
+ const diff = compareGraphSnapshots(stateDb2, timestamp_before, timestamp_after);
15202
+ result = {
15203
+ mode: "snapshot_diff",
15204
+ diff
15205
+ };
15206
+ break;
15207
+ }
14521
15208
  }
14522
15209
  return {
14523
15210
  content: [
@@ -15693,7 +16380,16 @@ function registerVaultResources(server2, getIndex) {
15693
16380
  }
15694
16381
 
15695
16382
  // src/index.ts
16383
+ var __filename = fileURLToPath(import.meta.url);
16384
+ var __dirname = dirname4(__filename);
16385
+ var pkg = JSON.parse(readFileSync4(join16(__dirname, "../package.json"), "utf-8"));
15696
16386
  var vaultPath = process.env.PROJECT_PATH || process.env.VAULT_PATH || findVaultRoot();
16387
+ var resolvedVaultPath;
16388
+ try {
16389
+ resolvedVaultPath = realpathSync(vaultPath).replace(/\\/g, "/");
16390
+ } catch {
16391
+ resolvedVaultPath = vaultPath.replace(/\\/g, "/");
16392
+ }
15697
16393
  var vaultIndex;
15698
16394
  var flywheelConfig = {};
15699
16395
  var stateDb = null;
@@ -15847,7 +16543,7 @@ var TOOL_CATEGORY = {
15847
16543
  };
15848
16544
  var server = new McpServer({
15849
16545
  name: "flywheel-memory",
15850
- version: "2.0.0"
16546
+ version: pkg.version
15851
16547
  });
15852
16548
  var _registeredCount = 0;
15853
16549
  var _skippedCount = 0;
@@ -15931,6 +16627,7 @@ registerSystemTools(
15931
16627
  () => vaultPath,
15932
16628
  (newConfig) => {
15933
16629
  flywheelConfig = newConfig;
16630
+ setWikilinkConfig(newConfig);
15934
16631
  },
15935
16632
  () => stateDb
15936
16633
  );
@@ -15957,6 +16654,7 @@ registerConfigTools(
15957
16654
  () => flywheelConfig,
15958
16655
  (newConfig) => {
15959
16656
  flywheelConfig = newConfig;
16657
+ setWikilinkConfig(newConfig);
15960
16658
  },
15961
16659
  () => stateDb
15962
16660
  );
@@ -15974,7 +16672,7 @@ registerMergeTools2(server, () => stateDb);
15974
16672
  registerVaultResources(server, () => vaultIndex ?? null);
15975
16673
  serverLog("server", `Registered ${_registeredCount} tools, skipped ${_skippedCount}`);
15976
16674
  async function main() {
15977
- serverLog("server", "Starting Flywheel Memory server...");
16675
+ serverLog("server", `Starting Flywheel Memory v${pkg.version}...`);
15978
16676
  serverLog("server", `Vault: ${vaultPath}`);
15979
16677
  const startTime = Date.now();
15980
16678
  try {
@@ -15986,6 +16684,7 @@ async function main() {
15986
16684
  serverLog("statedb", "Injected FTS5, embeddings, task cache handles");
15987
16685
  loadEntityEmbeddingsToMemory();
15988
16686
  setWriteStateDb(stateDb);
16687
+ setRecencyStateDb(stateDb);
15989
16688
  } catch (err) {
15990
16689
  const msg = err instanceof Error ? err.message : String(err);
15991
16690
  serverLog("statedb", `StateDb initialization failed: ${msg}`, "error");
@@ -16139,6 +16838,7 @@ async function runPostIndexWork(index) {
16139
16838
  saveConfig(stateDb, inferred, existing);
16140
16839
  }
16141
16840
  flywheelConfig = loadConfig(stateDb);
16841
+ setWikilinkConfig(flywheelConfig);
16142
16842
  const configKeys = Object.keys(flywheelConfig).filter((k) => flywheelConfig[k] != null);
16143
16843
  serverLog("config", `Config inferred: ${configKeys.join(", ")}`);
16144
16844
  if (stateDb) {
@@ -16190,6 +16890,48 @@ async function runPostIndexWork(index) {
16190
16890
  vaultPath,
16191
16891
  config,
16192
16892
  onBatch: async (batch) => {
16893
+ const vaultPrefixes = /* @__PURE__ */ new Set([
16894
+ vaultPath.replace(/\\/g, "/"),
16895
+ resolvedVaultPath
16896
+ ]);
16897
+ for (const event of batch.events) {
16898
+ const normalized = event.path.replace(/\\/g, "/");
16899
+ let matched = false;
16900
+ for (const prefix of vaultPrefixes) {
16901
+ if (normalized.startsWith(prefix + "/")) {
16902
+ event.path = normalized.slice(prefix.length + 1);
16903
+ matched = true;
16904
+ break;
16905
+ }
16906
+ }
16907
+ if (!matched) {
16908
+ try {
16909
+ const resolved = realpathSync(event.path).replace(/\\/g, "/");
16910
+ for (const prefix of vaultPrefixes) {
16911
+ if (resolved.startsWith(prefix + "/")) {
16912
+ event.path = resolved.slice(prefix.length + 1);
16913
+ matched = true;
16914
+ break;
16915
+ }
16916
+ }
16917
+ } catch {
16918
+ try {
16919
+ const dir = path29.dirname(event.path);
16920
+ const base = path29.basename(event.path);
16921
+ const resolvedDir = realpathSync(dir).replace(/\\/g, "/");
16922
+ for (const prefix of vaultPrefixes) {
16923
+ if (resolvedDir.startsWith(prefix + "/") || resolvedDir === prefix) {
16924
+ const relDir = resolvedDir === prefix ? "" : resolvedDir.slice(prefix.length + 1);
16925
+ event.path = relDir ? `${relDir}/${base}` : base;
16926
+ matched = true;
16927
+ break;
16928
+ }
16929
+ }
16930
+ } catch {
16931
+ }
16932
+ }
16933
+ }
16934
+ }
16193
16935
  serverLog("watcher", `Processing ${batch.events.length} file changes`);
16194
16936
  const batchStart = Date.now();
16195
16937
  const changedPaths = batch.events.map((e) => e.path);
@@ -16200,6 +16942,11 @@ async function runPostIndexWork(index) {
16200
16942
  setIndexState("ready");
16201
16943
  tracker.end({ note_count: vaultIndex.notes.size, entity_count: vaultIndex.entities.size, tag_count: vaultIndex.tags.size });
16202
16944
  serverLog("watcher", `Index rebuilt: ${vaultIndex.notes.size} notes, ${vaultIndex.entities.size} entities`);
16945
+ const hubBefore = /* @__PURE__ */ new Map();
16946
+ if (stateDb) {
16947
+ const rows = stateDb.db.prepare("SELECT name, hub_score FROM entities").all();
16948
+ for (const r of rows) hubBefore.set(r.name, r.hub_score);
16949
+ }
16203
16950
  const entitiesBefore = stateDb ? getAllEntitiesFromDb3(stateDb) : [];
16204
16951
  tracker.start("entity_scan", { note_count: vaultIndex.notes.size });
16205
16952
  await updateEntitiesInStateDb();
@@ -16207,11 +16954,6 @@ async function runPostIndexWork(index) {
16207
16954
  const entityDiff = computeEntityDiff(entitiesBefore, entitiesAfter);
16208
16955
  tracker.end({ entity_count: entitiesAfter.length, ...entityDiff });
16209
16956
  serverLog("watcher", `Entity scan: ${entitiesAfter.length} entities`);
16210
- const hubBefore = /* @__PURE__ */ new Map();
16211
- if (stateDb) {
16212
- const rows = stateDb.db.prepare("SELECT name, hub_score FROM entities").all();
16213
- for (const r of rows) hubBefore.set(r.name, r.hub_score);
16214
- }
16215
16957
  tracker.start("hub_scores", { entity_count: entitiesAfter.length });
16216
16958
  const hubUpdated = await exportHubScores(vaultIndex, stateDb);
16217
16959
  const hubDiffs = [];
@@ -16224,6 +16966,24 @@ async function runPostIndexWork(index) {
16224
16966
  }
16225
16967
  tracker.end({ updated: hubUpdated ?? 0, diffs: hubDiffs.slice(0, 10) });
16226
16968
  serverLog("watcher", `Hub scores: ${hubUpdated ?? 0} updated`);
16969
+ tracker.start("recency", { entity_count: entitiesAfter.length });
16970
+ try {
16971
+ const cachedRecency = loadRecencyFromStateDb();
16972
+ const cacheAgeMs = cachedRecency ? Date.now() - (cachedRecency.lastUpdated ?? 0) : Infinity;
16973
+ if (cacheAgeMs >= 60 * 60 * 1e3) {
16974
+ const entities = entitiesAfter.map((e) => ({ name: e.name, path: e.path, aliases: e.aliases }));
16975
+ const recencyIndex2 = await buildRecencyIndex(vaultPath, entities);
16976
+ saveRecencyToStateDb(recencyIndex2);
16977
+ tracker.end({ rebuilt: true, entities: recencyIndex2.lastMentioned.size });
16978
+ serverLog("watcher", `Recency: rebuilt ${recencyIndex2.lastMentioned.size} entities`);
16979
+ } else {
16980
+ tracker.end({ rebuilt: false, cached_age_ms: cacheAgeMs });
16981
+ serverLog("watcher", `Recency: cache valid (${Math.round(cacheAgeMs / 1e3)}s old)`);
16982
+ }
16983
+ } catch (e) {
16984
+ tracker.end({ error: String(e) });
16985
+ serverLog("watcher", `Recency: failed: ${e}`);
16986
+ }
16227
16987
  if (hasEmbeddingsIndex()) {
16228
16988
  tracker.start("note_embeddings", { files: batch.events.length });
16229
16989
  let embUpdated = 0;
@@ -16303,6 +17063,38 @@ async function runPostIndexWork(index) {
16303
17063
  }
16304
17064
  tracker.end({ updated: taskUpdated, removed: taskRemoved });
16305
17065
  serverLog("watcher", `Task cache: ${taskUpdated} updated, ${taskRemoved} removed`);
17066
+ tracker.start("forward_links", { files: batch.events.length });
17067
+ const forwardLinkResults = [];
17068
+ let totalResolved = 0;
17069
+ let totalDead = 0;
17070
+ for (const event of batch.events) {
17071
+ if (event.type === "delete" || !event.path.endsWith(".md")) continue;
17072
+ try {
17073
+ const links = getForwardLinksForNote(vaultIndex, event.path);
17074
+ const resolved = [];
17075
+ const dead = [];
17076
+ const seen = /* @__PURE__ */ new Set();
17077
+ for (const link of links) {
17078
+ const name = link.target;
17079
+ if (seen.has(name.toLowerCase())) continue;
17080
+ seen.add(name.toLowerCase());
17081
+ if (link.exists) resolved.push(name);
17082
+ else dead.push(name);
17083
+ }
17084
+ if (resolved.length > 0 || dead.length > 0) {
17085
+ forwardLinkResults.push({ file: event.path, resolved, dead });
17086
+ }
17087
+ totalResolved += resolved.length;
17088
+ totalDead += dead.length;
17089
+ } catch {
17090
+ }
17091
+ }
17092
+ tracker.end({
17093
+ total_resolved: totalResolved,
17094
+ total_dead: totalDead,
17095
+ links: forwardLinkResults
17096
+ });
17097
+ serverLog("watcher", `Forward links: ${totalResolved} resolved, ${totalDead} dead`);
16306
17098
  tracker.start("wikilink_check", { files: batch.events.length });
16307
17099
  const trackedLinks = [];
16308
17100
  if (stateDb) {
@@ -16376,6 +17168,8 @@ async function runPostIndexWork(index) {
16376
17168
  watcher.start();
16377
17169
  serverLog("watcher", "File watcher started");
16378
17170
  }
17171
+ startSweepTimer(() => vaultIndex);
17172
+ serverLog("server", "Sweep timer started (5 min interval)");
16379
17173
  const postDuration = Date.now() - postStart;
16380
17174
  serverLog("server", `Post-index work complete in ${postDuration}ms`);
16381
17175
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.0.33",
3
+ "version": "2.0.35",
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",
@@ -42,6 +42,8 @@
42
42
  "test:write": "vitest run test/write/",
43
43
  "test:security": "vitest run test/write/security/",
44
44
  "test:stress": "vitest run test/write/stress/ test/write/battle-hardening/",
45
+ "test:quality": "vitest run test/graph-quality/",
46
+ "test:quality:report": "tsx test/graph-quality/generate-proof.ts",
45
47
  "test:coverage": "vitest run --coverage",
46
48
  "test:ci": "vitest run --reporter=github-actions",
47
49
  "lint": "tsc --noEmit",
@@ -50,7 +52,7 @@
50
52
  },
51
53
  "dependencies": {
52
54
  "@modelcontextprotocol/sdk": "^1.25.1",
53
- "@velvetmonkey/vault-core": "^2.0.33",
55
+ "@velvetmonkey/vault-core": "^2.0.34",
54
56
  "better-sqlite3": "^11.0.0",
55
57
  "chokidar": "^4.0.0",
56
58
  "gray-matter": "^4.0.3",