@velvetmonkey/flywheel-memory 2.0.34 → 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 +483 -55
  2. package/package.json +3 -1
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()) {
@@ -3241,9 +3251,14 @@ var FEEDBACK_BOOST_TIERS = [
3241
3251
  { minAccuracy: 0, minSamples: 5, boost: -4 }
3242
3252
  ];
3243
3253
  function recordFeedback(stateDb2, entity, context, notePath, correct) {
3244
- stateDb2.db.prepare(
3245
- "INSERT INTO wikilink_feedback (entity, context, note_path, correct) VALUES (?, ?, ?, ?)"
3246
- ).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
+ }
3247
3262
  }
3248
3263
  function getFeedback(stateDb2, entity, limit = 20) {
3249
3264
  let rows;
@@ -3554,6 +3569,159 @@ function getDashboardData(stateDb2) {
3554
3569
  }))
3555
3570
  };
3556
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
+ }
3557
3725
 
3558
3726
  // src/core/write/git.ts
3559
3727
  import { simpleGit, CheckRepoActions } from "simple-git";
@@ -3986,14 +4154,16 @@ function loadRecencyFromStateDb() {
3986
4154
  }
3987
4155
  function saveRecencyToStateDb(index) {
3988
4156
  if (!moduleStateDb3) {
3989
- console.error("[Flywheel] No StateDb available for saving recency");
4157
+ console.error("[Flywheel] saveRecencyToStateDb: No StateDb available (moduleStateDb is null)");
3990
4158
  return;
3991
4159
  }
4160
+ console.error(`[Flywheel] saveRecencyToStateDb: Saving ${index.lastMentioned.size} entries...`);
3992
4161
  try {
3993
4162
  for (const [entityNameLower, timestamp] of index.lastMentioned) {
3994
4163
  recordEntityMention(moduleStateDb3, entityNameLower, new Date(timestamp));
3995
4164
  }
3996
- 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`);
3997
4167
  } catch (e) {
3998
4168
  console.error("[Flywheel] Failed to save recency to StateDb:", e);
3999
4169
  }
@@ -5084,7 +5254,8 @@ function processWikilinks(content, notePath) {
5084
5254
  caseInsensitive: true
5085
5255
  });
5086
5256
  const implicitEnabled = moduleConfig?.implicit_detection !== false;
5087
- const implicitPatterns = moduleConfig?.implicit_patterns?.length ? moduleConfig.implicit_patterns : [...ALL_IMPLICIT_PATTERNS];
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];
5088
5259
  const implicitMatches = detectImplicitEntities(result.content, {
5089
5260
  detectImplicit: implicitEnabled,
5090
5261
  implicitPatterns,
@@ -5505,8 +5676,10 @@ async function suggestRelatedLinks(content, options = {}) {
5505
5676
  excludeLinked = true,
5506
5677
  strictness = getEffectiveStrictness(options.notePath),
5507
5678
  notePath,
5508
- detail = false
5679
+ detail = false,
5680
+ disabledLayers = []
5509
5681
  } = options;
5682
+ const disabled = new Set(disabledLayers);
5510
5683
  const config = STRICTNESS_CONFIGS[strictness];
5511
5684
  const adaptiveMinScore = getAdaptiveMinScore(content.length, config.minSuggestionScore);
5512
5685
  const noteContext = notePath ? getNoteContext(notePath) : "general";
@@ -5547,31 +5720,31 @@ async function suggestRelatedLinks(content, options = {}) {
5547
5720
  for (const { entity, category } of entitiesWithTypes) {
5548
5721
  const entityName = entity.name;
5549
5722
  if (!entityName) continue;
5550
- if (entityName.length > MAX_ENTITY_LENGTH) {
5723
+ if (!disabled.has("length_filter") && entityName.length > MAX_ENTITY_LENGTH) {
5551
5724
  continue;
5552
5725
  }
5553
- if (isLikelyArticleTitle(entityName)) {
5726
+ if (!disabled.has("article_filter") && isLikelyArticleTitle(entityName)) {
5554
5727
  continue;
5555
5728
  }
5556
5729
  if (linkedEntities.has(entityName.toLowerCase())) {
5557
5730
  continue;
5558
5731
  }
5559
- const contentScore = scoreEntity(entity, contentTokens, contentStems, config);
5732
+ const contentScore = disabled.has("exact_match") && disabled.has("stem_match") ? 0 : scoreEntity(entity, contentTokens, contentStems, config);
5560
5733
  let score = contentScore;
5561
5734
  if (contentScore > 0) {
5562
5735
  entitiesWithContentMatch.add(entityName);
5563
5736
  }
5564
- const layerTypeBoost = TYPE_BOOST[category] || 0;
5737
+ const layerTypeBoost = disabled.has("type_boost") ? 0 : TYPE_BOOST[category] || 0;
5565
5738
  score += layerTypeBoost;
5566
- const layerContextBoost = contextBoosts[category] || 0;
5739
+ const layerContextBoost = disabled.has("context_boost") ? 0 : contextBoosts[category] || 0;
5567
5740
  score += layerContextBoost;
5568
- const layerRecencyBoost = recencyIndex ? getRecencyBoost(entityName, recencyIndex) : 0;
5741
+ const layerRecencyBoost = disabled.has("recency") ? 0 : recencyIndex ? getRecencyBoost(entityName, recencyIndex) : 0;
5569
5742
  score += layerRecencyBoost;
5570
- 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;
5571
5744
  score += layerCrossFolderBoost;
5572
- const layerHubBoost = getHubBoost(entity);
5745
+ const layerHubBoost = disabled.has("hub_boost") ? 0 : getHubBoost(entity);
5573
5746
  score += layerHubBoost;
5574
- const layerFeedbackAdj = feedbackBoosts.get(entityName) ?? 0;
5747
+ const layerFeedbackAdj = disabled.has("feedback") ? 0 : feedbackBoosts.get(entityName) ?? 0;
5575
5748
  score += layerFeedbackAdj;
5576
5749
  if (score > 0) {
5577
5750
  directlyMatchedEntities.add(entityName);
@@ -5595,12 +5768,12 @@ async function suggestRelatedLinks(content, options = {}) {
5595
5768
  });
5596
5769
  }
5597
5770
  }
5598
- if (cooccurrenceIndex && directlyMatchedEntities.size > 0) {
5771
+ if (!disabled.has("cooccurrence") && cooccurrenceIndex && directlyMatchedEntities.size > 0) {
5599
5772
  for (const { entity, category } of entitiesWithTypes) {
5600
5773
  const entityName = entity.name;
5601
5774
  if (!entityName) continue;
5602
- if (entityName.length > MAX_ENTITY_LENGTH) continue;
5603
- 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;
5604
5777
  if (linkedEntities.has(entityName.toLowerCase())) continue;
5605
5778
  const boost = getCooccurrenceBoost(entityName, directlyMatchedEntities, cooccurrenceIndex, recencyIndex);
5606
5779
  if (boost > 0) {
@@ -5617,12 +5790,12 @@ async function suggestRelatedLinks(content, options = {}) {
5617
5790
  continue;
5618
5791
  }
5619
5792
  entitiesWithContentMatch.add(entityName);
5620
- const typeBoost = TYPE_BOOST[category] || 0;
5621
- const contextBoost = contextBoosts[category] || 0;
5622
- const recencyBoostVal = recencyIndex ? getRecencyBoost(entityName, recencyIndex) : 0;
5623
- const crossFolderBoost = notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
5624
- const hubBoost = getHubBoost(entity);
5625
- 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;
5626
5799
  const totalBoost = boost + typeBoost + contextBoost + recencyBoostVal + crossFolderBoost + hubBoost + feedbackAdj;
5627
5800
  if (totalBoost >= adaptiveMinScore) {
5628
5801
  scoredEntities.push({
@@ -5646,7 +5819,7 @@ async function suggestRelatedLinks(content, options = {}) {
5646
5819
  }
5647
5820
  }
5648
5821
  }
5649
- if (content.length >= 20 && hasEntityEmbeddingsIndex()) {
5822
+ if (!disabled.has("semantic") && content.length >= 20 && hasEntityEmbeddingsIndex()) {
5650
5823
  try {
5651
5824
  const contentEmbedding = await embedTextCached(content);
5652
5825
  const alreadyScoredNames = new Set(scoredEntities.map((e) => e.name));
@@ -5668,14 +5841,14 @@ async function suggestRelatedLinks(content, options = {}) {
5668
5841
  (et) => et.entity.name === match.entityName
5669
5842
  );
5670
5843
  if (!entityWithType) continue;
5671
- if (match.entityName.length > MAX_ENTITY_LENGTH) continue;
5672
- 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;
5673
5846
  const { entity, category } = entityWithType;
5674
- const layerTypeBoost = TYPE_BOOST[category] || 0;
5675
- const layerContextBoost = contextBoosts[category] || 0;
5676
- const layerHubBoost = getHubBoost(entity);
5677
- const layerCrossFolderBoost = notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
5678
- 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;
5679
5852
  const totalScore = boost + layerTypeBoost + layerContextBoost + layerHubBoost + layerCrossFolderBoost + layerFeedbackAdj;
5680
5853
  if (totalScore >= adaptiveMinScore) {
5681
5854
  scoredEntities.push({
@@ -5717,7 +5890,51 @@ async function suggestRelatedLinks(content, options = {}) {
5717
5890
  }
5718
5891
  return 0;
5719
5892
  });
5720
- 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);
5721
5938
  const topSuggestions = topEntries.map((e) => e.name);
5722
5939
  if (topSuggestions.length === 0) {
5723
5940
  return emptyResult;
@@ -10295,6 +10512,49 @@ function getEmergingHubs(stateDb2, daysBack = 30) {
10295
10512
  emerging.sort((a, b) => b.growth - a.growth);
10296
10513
  return emerging;
10297
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
+ }
10298
10558
  function purgeOldSnapshots(stateDb2, retentionDays = 90) {
10299
10559
  const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1e3;
10300
10560
  const result = stateDb2.db.prepare(
@@ -11966,10 +12226,11 @@ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", con
11966
12226
  validate: z11.boolean().default(true).describe("Check input for common issues (double timestamps, non-markdown bullets, etc.)"),
11967
12227
  normalize: z11.boolean().default(true).describe("Auto-fix common issues before formatting (replace \u2022 with -, trim excessive whitespace, etc.)"),
11968
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."),
11969
12230
  agent_id: z11.string().optional().describe('Agent identifier for multi-agent scoping (e.g., "claude-opus", "planning-agent")'),
11970
12231
  session_id: z11.string().optional().describe('Session identifier for conversation scoping (e.g., "sess-abc123")')
11971
12232
  },
11972
- 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 }) => {
11973
12234
  let noteCreated = false;
11974
12235
  let templateUsed;
11975
12236
  if (create_if_missing) {
@@ -12004,6 +12265,12 @@ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", con
12004
12265
  }
12005
12266
  let workingContent = validationResult.content;
12006
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
+ }
12007
12274
  const _debug = {
12008
12275
  entityCount: getEntityIndexStats().totalEntities,
12009
12276
  indexReady: getEntityIndexStats().ready,
@@ -12439,7 +12706,7 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
12439
12706
  overwrite: z14.boolean().default(false).describe("If true, overwrite existing file"),
12440
12707
  commit: z14.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
12441
12708
  skipWikilinks: z14.boolean().default(false).describe("If true, skip auto-wikilink application (wikilinks are applied by default)"),
12442
- 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]]").'),
12443
12710
  maxSuggestions: z14.number().min(1).max(10).default(3).describe("Maximum number of suggested wikilinks to append (1-10, default: 3)"),
12444
12711
  agent_id: z14.string().optional().describe("Agent identifier for multi-agent scoping"),
12445
12712
  session_id: z14.string().optional().describe("Session identifier for conversation scoping")
@@ -13456,12 +13723,17 @@ async function executeStep(step, vaultPath2, context, conditionResults) {
13456
13723
  const resolvedParams = interpolateObject(step.params, context);
13457
13724
  try {
13458
13725
  const result = await executeToolCall(step.tool, resolvedParams, vaultPath2, context);
13726
+ const outputs = {};
13727
+ if (result.path) {
13728
+ outputs.path = result.path;
13729
+ }
13459
13730
  return {
13460
13731
  stepId: step.id,
13461
13732
  success: result.success,
13462
13733
  message: result.message,
13463
13734
  path: result.path,
13464
- preview: result.preview
13735
+ preview: result.preview,
13736
+ outputs
13465
13737
  };
13466
13738
  } catch (error) {
13467
13739
  return {
@@ -13852,6 +14124,9 @@ async function executePolicy(policy, vaultPath2, variables, commit = false) {
13852
14124
  if (result.path && result.success && !result.skipped) {
13853
14125
  filesModified.add(result.path);
13854
14126
  }
14127
+ if (result.success && !result.skipped && result.outputs) {
14128
+ context.steps[step.id] = result.outputs;
14129
+ }
13855
14130
  if (!result.success && !result.skipped) {
13856
14131
  if (commit && filesModified.size > 0) {
13857
14132
  await rollbackChanges(vaultPath2, originalContents, filesModified);
@@ -14812,21 +15087,26 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
14812
15087
  "wikilink_feedback",
14813
15088
  {
14814
15089
  title: "Wikilink Feedback",
14815
- 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).',
14816
15091
  inputSchema: {
14817
- mode: z21.enum(["report", "list", "stats", "dashboard"]).describe("Operation mode"),
14818
- 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)"),
14819
15094
  note_path: z21.string().optional().describe("Note path where the wikilink appeared (for report mode)"),
14820
15095
  context: z21.string().optional().describe("Surrounding text context (for report mode)"),
14821
15096
  correct: z21.boolean().optional().describe("Whether the wikilink was correct (for report mode)"),
14822
- 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")
14823
15102
  }
14824
15103
  },
14825
- async ({ mode, entity, note_path, context, correct, limit }) => {
15104
+ async ({ mode, entity, note_path, context, correct, limit, days_back, granularity, timestamp_before, timestamp_after }) => {
14826
15105
  const stateDb2 = getStateDb();
14827
15106
  if (!stateDb2) {
14828
15107
  return {
14829
- 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
14830
15110
  };
14831
15111
  }
14832
15112
  let result;
@@ -14837,7 +15117,15 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
14837
15117
  content: [{ type: "text", text: JSON.stringify({ error: "entity and correct are required for report mode" }) }]
14838
15118
  };
14839
15119
  }
14840
- 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
+ }
14841
15129
  const suppressionUpdated = updateSuppressionList(stateDb2) > 0;
14842
15130
  result = {
14843
15131
  mode: "report",
@@ -14870,7 +15158,7 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
14870
15158
  break;
14871
15159
  }
14872
15160
  case "dashboard": {
14873
- const dashboard = getDashboardData(stateDb2);
15161
+ const dashboard = getExtendedDashboardData(stateDb2);
14874
15162
  result = {
14875
15163
  mode: "dashboard",
14876
15164
  dashboard,
@@ -14879,6 +15167,44 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
14879
15167
  };
14880
15168
  break;
14881
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
+ }
14882
15208
  }
14883
15209
  return {
14884
15210
  content: [
@@ -16054,7 +16380,16 @@ function registerVaultResources(server2, getIndex) {
16054
16380
  }
16055
16381
 
16056
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"));
16057
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
+ }
16058
16393
  var vaultIndex;
16059
16394
  var flywheelConfig = {};
16060
16395
  var stateDb = null;
@@ -16208,7 +16543,7 @@ var TOOL_CATEGORY = {
16208
16543
  };
16209
16544
  var server = new McpServer({
16210
16545
  name: "flywheel-memory",
16211
- version: "2.0.0"
16546
+ version: pkg.version
16212
16547
  });
16213
16548
  var _registeredCount = 0;
16214
16549
  var _skippedCount = 0;
@@ -16337,7 +16672,7 @@ registerMergeTools2(server, () => stateDb);
16337
16672
  registerVaultResources(server, () => vaultIndex ?? null);
16338
16673
  serverLog("server", `Registered ${_registeredCount} tools, skipped ${_skippedCount}`);
16339
16674
  async function main() {
16340
- serverLog("server", "Starting Flywheel Memory server...");
16675
+ serverLog("server", `Starting Flywheel Memory v${pkg.version}...`);
16341
16676
  serverLog("server", `Vault: ${vaultPath}`);
16342
16677
  const startTime = Date.now();
16343
16678
  try {
@@ -16349,6 +16684,7 @@ async function main() {
16349
16684
  serverLog("statedb", "Injected FTS5, embeddings, task cache handles");
16350
16685
  loadEntityEmbeddingsToMemory();
16351
16686
  setWriteStateDb(stateDb);
16687
+ setRecencyStateDb(stateDb);
16352
16688
  } catch (err) {
16353
16689
  const msg = err instanceof Error ? err.message : String(err);
16354
16690
  serverLog("statedb", `StateDb initialization failed: ${msg}`, "error");
@@ -16554,6 +16890,48 @@ async function runPostIndexWork(index) {
16554
16890
  vaultPath,
16555
16891
  config,
16556
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
+ }
16557
16935
  serverLog("watcher", `Processing ${batch.events.length} file changes`);
16558
16936
  const batchStart = Date.now();
16559
16937
  const changedPaths = batch.events.map((e) => e.path);
@@ -16564,6 +16942,11 @@ async function runPostIndexWork(index) {
16564
16942
  setIndexState("ready");
16565
16943
  tracker.end({ note_count: vaultIndex.notes.size, entity_count: vaultIndex.entities.size, tag_count: vaultIndex.tags.size });
16566
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
+ }
16567
16950
  const entitiesBefore = stateDb ? getAllEntitiesFromDb3(stateDb) : [];
16568
16951
  tracker.start("entity_scan", { note_count: vaultIndex.notes.size });
16569
16952
  await updateEntitiesInStateDb();
@@ -16571,11 +16954,6 @@ async function runPostIndexWork(index) {
16571
16954
  const entityDiff = computeEntityDiff(entitiesBefore, entitiesAfter);
16572
16955
  tracker.end({ entity_count: entitiesAfter.length, ...entityDiff });
16573
16956
  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
- }
16579
16957
  tracker.start("hub_scores", { entity_count: entitiesAfter.length });
16580
16958
  const hubUpdated = await exportHubScores(vaultIndex, stateDb);
16581
16959
  const hubDiffs = [];
@@ -16588,6 +16966,24 @@ async function runPostIndexWork(index) {
16588
16966
  }
16589
16967
  tracker.end({ updated: hubUpdated ?? 0, diffs: hubDiffs.slice(0, 10) });
16590
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
+ }
16591
16987
  if (hasEmbeddingsIndex()) {
16592
16988
  tracker.start("note_embeddings", { files: batch.events.length });
16593
16989
  let embUpdated = 0;
@@ -16667,6 +17063,38 @@ async function runPostIndexWork(index) {
16667
17063
  }
16668
17064
  tracker.end({ updated: taskUpdated, removed: taskRemoved });
16669
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`);
16670
17098
  tracker.start("wikilink_check", { files: batch.events.length });
16671
17099
  const trackedLinks = [];
16672
17100
  if (stateDb) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.0.34",
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",