@velvetmonkey/flywheel-memory 2.0.42 → 2.0.44

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 +285 -165
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -3058,7 +3058,7 @@ var DEFAULT_WATCHER_CONFIG = {
3058
3058
  flushMs: 1e3,
3059
3059
  batchSize: 50,
3060
3060
  usePolling: false,
3061
- pollInterval: 3e4
3061
+ pollInterval: 1e4
3062
3062
  };
3063
3063
  function parseWatcherConfig() {
3064
3064
  const debounceMs = parseInt(process.env.FLYWHEEL_DEBOUNCE_MS || "");
@@ -3627,9 +3627,12 @@ function getSuppressedCount(stateDb2) {
3627
3627
  return row.count;
3628
3628
  }
3629
3629
  function getSuppressedEntities(stateDb2) {
3630
- return stateDb2.db.prepare(
3631
- "SELECT entity, false_positive_rate FROM wikilink_suppressions ORDER BY false_positive_rate DESC"
3632
- ).all();
3630
+ return stateDb2.db.prepare(`
3631
+ SELECT s.entity, s.false_positive_rate,
3632
+ COALESCE((SELECT COUNT(*) FROM wikilink_feedback WHERE entity = s.entity), 0) as total
3633
+ FROM wikilink_suppressions s
3634
+ ORDER BY s.false_positive_rate DESC
3635
+ `).all();
3633
3636
  }
3634
3637
  function computeBoostFromAccuracy(accuracy, sampleCount) {
3635
3638
  if (sampleCount < FEEDBACK_BOOST_MIN_SAMPLES) return 0;
@@ -3784,6 +3787,9 @@ function processImplicitFeedback(stateDb2, notePath, currentContent) {
3784
3787
  }
3785
3788
  });
3786
3789
  transaction();
3790
+ if (removed.length > 0) {
3791
+ updateSuppressionList(stateDb2);
3792
+ }
3787
3793
  return removed;
3788
3794
  }
3789
3795
  var TIER_LABELS = [
@@ -5372,16 +5378,186 @@ function getCooccurrenceBoost(entityName, matchedEntities, cooccurrenceIndex2, r
5372
5378
  return Math.min(boost, MAX_COOCCURRENCE_BOOST);
5373
5379
  }
5374
5380
 
5375
- // src/core/write/wikilinks.ts
5381
+ // src/core/write/edgeWeights.ts
5376
5382
  var moduleStateDb4 = null;
5377
- function setWriteStateDb(stateDb2) {
5383
+ function setEdgeWeightStateDb(stateDb2) {
5378
5384
  moduleStateDb4 = stateDb2;
5385
+ }
5386
+ function buildPathToTargetsMap(stateDb2) {
5387
+ const map = /* @__PURE__ */ new Map();
5388
+ const rows = stateDb2.db.prepare(
5389
+ "SELECT path, name_lower, aliases_json FROM entities"
5390
+ ).all();
5391
+ for (const row of rows) {
5392
+ const targets = /* @__PURE__ */ new Set();
5393
+ targets.add(row.name_lower);
5394
+ if (row.aliases_json) {
5395
+ try {
5396
+ const aliases = JSON.parse(row.aliases_json);
5397
+ for (const alias of aliases) {
5398
+ targets.add(alias.toLowerCase());
5399
+ }
5400
+ } catch {
5401
+ }
5402
+ }
5403
+ map.set(row.path, targets);
5404
+ }
5405
+ return map;
5406
+ }
5407
+ function pathToFallbackTarget(filePath) {
5408
+ return filePath.replace(/\.md$/, "").split("/").pop()?.toLowerCase() ?? filePath.toLowerCase();
5409
+ }
5410
+ function recomputeEdgeWeights(stateDb2) {
5411
+ const start = Date.now();
5412
+ const edges = stateDb2.db.prepare(
5413
+ "SELECT note_path, target FROM note_links"
5414
+ ).all();
5415
+ if (edges.length === 0) {
5416
+ return { edges_updated: 0, duration_ms: Date.now() - start, total_weighted: 0, avg_weight: 0, strong_count: 0, top_changes: [] };
5417
+ }
5418
+ const survivalMap = /* @__PURE__ */ new Map();
5419
+ const historyRows = stateDb2.db.prepare(
5420
+ "SELECT note_path, target, edits_survived FROM note_link_history"
5421
+ ).all();
5422
+ for (const row of historyRows) {
5423
+ survivalMap.set(`${row.note_path}\0${row.target}`, row.edits_survived);
5424
+ }
5425
+ const pathToTargets = buildPathToTargetsMap(stateDb2);
5426
+ const targetToPaths = /* @__PURE__ */ new Map();
5427
+ for (const [entityPath, targets] of pathToTargets) {
5428
+ for (const target of targets) {
5429
+ let paths = targetToPaths.get(target);
5430
+ if (!paths) {
5431
+ paths = /* @__PURE__ */ new Set();
5432
+ targetToPaths.set(target, paths);
5433
+ }
5434
+ paths.add(entityPath);
5435
+ }
5436
+ }
5437
+ const sessionRows = stateDb2.db.prepare(
5438
+ `SELECT session_id, note_paths FROM tool_invocations
5439
+ WHERE note_paths IS NOT NULL AND note_paths != '[]'`
5440
+ ).all();
5441
+ const sessionPaths = /* @__PURE__ */ new Map();
5442
+ for (const row of sessionRows) {
5443
+ try {
5444
+ const paths = JSON.parse(row.note_paths);
5445
+ if (!Array.isArray(paths) || paths.length === 0) continue;
5446
+ let existing = sessionPaths.get(row.session_id);
5447
+ if (!existing) {
5448
+ existing = /* @__PURE__ */ new Set();
5449
+ sessionPaths.set(row.session_id, existing);
5450
+ }
5451
+ for (const p of paths) {
5452
+ existing.add(p);
5453
+ }
5454
+ } catch {
5455
+ }
5456
+ }
5457
+ const coSessionCount = /* @__PURE__ */ new Map();
5458
+ const sourceActivityCount = /* @__PURE__ */ new Map();
5459
+ for (const [, paths] of sessionPaths) {
5460
+ const sessionTargets = /* @__PURE__ */ new Set();
5461
+ for (const p of paths) {
5462
+ const targets = pathToTargets.get(p);
5463
+ if (targets) {
5464
+ for (const t of targets) sessionTargets.add(t);
5465
+ } else {
5466
+ sessionTargets.add(pathToFallbackTarget(p));
5467
+ }
5468
+ }
5469
+ for (const edge of edges) {
5470
+ if (paths.has(edge.note_path)) {
5471
+ const srcKey = edge.note_path;
5472
+ sourceActivityCount.set(srcKey, (sourceActivityCount.get(srcKey) ?? 0) + 1);
5473
+ if (sessionTargets.has(edge.target)) {
5474
+ const edgeKey = `${edge.note_path}\0${edge.target}`;
5475
+ coSessionCount.set(edgeKey, (coSessionCount.get(edgeKey) ?? 0) + 1);
5476
+ }
5477
+ }
5478
+ }
5479
+ }
5480
+ const oldWeights = /* @__PURE__ */ new Map();
5481
+ const oldRows = stateDb2.db.prepare(
5482
+ "SELECT note_path, target, weight FROM note_links"
5483
+ ).all();
5484
+ for (const row of oldRows) {
5485
+ oldWeights.set(`${row.note_path}\0${row.target}`, row.weight);
5486
+ }
5487
+ const now = Date.now();
5488
+ const update = stateDb2.db.prepare(
5489
+ "UPDATE note_links SET weight = ?, weight_updated_at = ? WHERE note_path = ? AND target = ?"
5490
+ );
5491
+ const changes = [];
5492
+ const tx = stateDb2.db.transaction(() => {
5493
+ for (const edge of edges) {
5494
+ const edgeKey = `${edge.note_path}\0${edge.target}`;
5495
+ const editsSurvived = survivalMap.get(edgeKey) ?? 0;
5496
+ const coSessions = coSessionCount.get(edgeKey) ?? 0;
5497
+ const sourceAccess = sourceActivityCount.get(edge.note_path) ?? 0;
5498
+ const weight = 1 + editsSurvived * 0.5 + Math.min(coSessions * 0.5, 3) + Math.min(sourceAccess * 0.2, 2);
5499
+ const roundedWeight = Math.round(weight * 1e3) / 1e3;
5500
+ const oldWeight = oldWeights.get(edgeKey) ?? 1;
5501
+ const delta = roundedWeight - oldWeight;
5502
+ if (Math.abs(delta) >= 1e-3) {
5503
+ changes.push({
5504
+ note_path: edge.note_path,
5505
+ target: edge.target,
5506
+ old_weight: oldWeight,
5507
+ new_weight: roundedWeight,
5508
+ delta: Math.round(delta * 1e3) / 1e3,
5509
+ edits_survived: editsSurvived,
5510
+ co_sessions: coSessions,
5511
+ source_access: sourceAccess
5512
+ });
5513
+ }
5514
+ update.run(roundedWeight, now, edge.note_path, edge.target);
5515
+ }
5516
+ });
5517
+ tx();
5518
+ changes.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
5519
+ const top_changes = changes.slice(0, 10);
5520
+ const stats = stateDb2.db.prepare(`
5521
+ SELECT
5522
+ COUNT(*) as total_weighted,
5523
+ AVG(weight) as avg_weight,
5524
+ SUM(CASE WHEN weight > 3.0 THEN 1 ELSE 0 END) as strong_count
5525
+ FROM note_links
5526
+ WHERE weight > 1.0
5527
+ `).get();
5528
+ return {
5529
+ edges_updated: edges.length,
5530
+ duration_ms: Date.now() - start,
5531
+ total_weighted: stats?.total_weighted ?? 0,
5532
+ avg_weight: Math.round((stats?.avg_weight ?? 0) * 100) / 100,
5533
+ strong_count: stats?.strong_count ?? 0,
5534
+ top_changes
5535
+ };
5536
+ }
5537
+ function getEntityEdgeWeightMap(stateDb2) {
5538
+ const rows = stateDb2.db.prepare(`
5539
+ SELECT LOWER(target) as target_lower, AVG(weight) as avg_weight
5540
+ FROM note_links
5541
+ WHERE weight > 1.0
5542
+ GROUP BY LOWER(target)
5543
+ `).all();
5544
+ const map = /* @__PURE__ */ new Map();
5545
+ for (const row of rows) {
5546
+ map.set(row.target_lower, row.avg_weight);
5547
+ }
5548
+ return map;
5549
+ }
5550
+
5551
+ // src/core/write/wikilinks.ts
5552
+ var moduleStateDb5 = null;
5553
+ function setWriteStateDb(stateDb2) {
5554
+ moduleStateDb5 = stateDb2;
5379
5555
  setGitStateDb(stateDb2);
5380
5556
  setHintsStateDb(stateDb2);
5381
5557
  setRecencyStateDb(stateDb2);
5382
5558
  }
5383
5559
  function getWriteStateDb() {
5384
- return moduleStateDb4;
5560
+ return moduleStateDb5;
5385
5561
  }
5386
5562
  var moduleConfig = null;
5387
5563
  var ALL_IMPLICIT_PATTERNS = ["proper-nouns", "single-caps", "quoted-terms", "camel-case", "acronyms"];
@@ -5436,9 +5612,9 @@ var DEFAULT_EXCLUDE_FOLDERS = [
5436
5612
  ];
5437
5613
  async function initializeEntityIndex(vaultPath2) {
5438
5614
  try {
5439
- if (moduleStateDb4) {
5615
+ if (moduleStateDb5) {
5440
5616
  try {
5441
- const dbIndex = getEntityIndexFromDb(moduleStateDb4);
5617
+ const dbIndex = getEntityIndexFromDb(moduleStateDb5);
5442
5618
  if (dbIndex._metadata.total_entities > 0) {
5443
5619
  entityIndex = dbIndex;
5444
5620
  indexReady = true;
@@ -5466,9 +5642,9 @@ async function rebuildIndex(vaultPath2) {
5466
5642
  lastLoadedAt = Date.now();
5467
5643
  const entityDuration = Date.now() - startTime;
5468
5644
  console.error(`[Flywheel] Entity index built: ${entityIndex._metadata.total_entities} entities in ${entityDuration}ms`);
5469
- if (moduleStateDb4) {
5645
+ if (moduleStateDb5) {
5470
5646
  try {
5471
- moduleStateDb4.replaceAllEntities(entityIndex);
5647
+ moduleStateDb5.replaceAllEntities(entityIndex);
5472
5648
  console.error(`[Flywheel] Saved entities to StateDb`);
5473
5649
  } catch (e) {
5474
5650
  console.error(`[Flywheel] Failed to save entities to StateDb: ${e}`);
@@ -5505,14 +5681,14 @@ function isEntityIndexReady() {
5505
5681
  return indexReady && entityIndex !== null;
5506
5682
  }
5507
5683
  function checkAndRefreshIfStale() {
5508
- if (!moduleStateDb4 || !indexReady) return;
5684
+ if (!moduleStateDb5 || !indexReady) return;
5509
5685
  try {
5510
- const metadata = getStateDbMetadata(moduleStateDb4);
5686
+ const metadata = getStateDbMetadata(moduleStateDb5);
5511
5687
  if (!metadata.entitiesBuiltAt) return;
5512
5688
  const dbBuiltAt = new Date(metadata.entitiesBuiltAt).getTime();
5513
5689
  if (dbBuiltAt > lastLoadedAt) {
5514
5690
  console.error("[Flywheel] Entity index stale, reloading from StateDb...");
5515
- const dbIndex = getEntityIndexFromDb(moduleStateDb4);
5691
+ const dbIndex = getEntityIndexFromDb(moduleStateDb5);
5516
5692
  if (dbIndex._metadata.total_entities > 0) {
5517
5693
  entityIndex = dbIndex;
5518
5694
  lastLoadedAt = Date.now();
@@ -5554,11 +5730,11 @@ function processWikilinks(content, notePath, existingContent) {
5554
5730
  }
5555
5731
  let entities = getAllEntities(entityIndex);
5556
5732
  console.error(`[Flywheel:DEBUG] Processing wikilinks with ${entities.length} entities`);
5557
- if (moduleStateDb4) {
5733
+ if (moduleStateDb5) {
5558
5734
  const folder = notePath ? notePath.split("/")[0] : void 0;
5559
5735
  entities = entities.filter((e) => {
5560
5736
  const name = getEntityName2(e);
5561
- return !isSuppressed(moduleStateDb4, name, folder);
5737
+ return !isSuppressed(moduleStateDb5, name, folder);
5562
5738
  });
5563
5739
  }
5564
5740
  const sortedEntities = sortEntitiesByPriority(entities, notePath);
@@ -5638,8 +5814,8 @@ function maybeApplyWikilinks(content, skipWikilinks, notePath, existingContent)
5638
5814
  checkAndRefreshIfStale();
5639
5815
  const result = processWikilinks(content, notePath, existingContent);
5640
5816
  if (result.linksAdded > 0) {
5641
- if (moduleStateDb4 && notePath) {
5642
- trackWikilinkApplications(moduleStateDb4, notePath, result.linkedEntities);
5817
+ if (moduleStateDb5 && notePath) {
5818
+ trackWikilinkApplications(moduleStateDb5, notePath, result.linkedEntities);
5643
5819
  }
5644
5820
  const implicitCount = result.implicitEntities?.length ?? 0;
5645
5821
  const implicitInfo = implicitCount > 0 ? ` + ${implicitCount} implicit: ${result.implicitEntities.join(", ")}` : "";
@@ -5993,6 +6169,11 @@ function scoreEntity(entity, contentTokens, contentStems, config) {
5993
6169
  }
5994
6170
  return score;
5995
6171
  }
6172
+ function getEdgeWeightBoostScore(entityName, map) {
6173
+ const avgWeight = map.get(entityName.toLowerCase());
6174
+ if (!avgWeight) return 0;
6175
+ return Math.min((avgWeight - 1) * 2, 4);
6176
+ }
5996
6177
  async function suggestRelatedLinks(content, options = {}) {
5997
6178
  const {
5998
6179
  maxSuggestions = 3,
@@ -6036,7 +6217,8 @@ async function suggestRelatedLinks(content, options = {}) {
6036
6217
  }
6037
6218
  const linkedEntities = excludeLinked ? extractLinkedEntities(content) : /* @__PURE__ */ new Set();
6038
6219
  const noteFolder = notePath ? notePath.split("/")[0] : void 0;
6039
- const feedbackBoosts = moduleStateDb4 ? getAllFeedbackBoosts(moduleStateDb4, noteFolder) : /* @__PURE__ */ new Map();
6220
+ const feedbackBoosts = moduleStateDb5 ? getAllFeedbackBoosts(moduleStateDb5, noteFolder) : /* @__PURE__ */ new Map();
6221
+ const edgeWeightMap = moduleStateDb5 ? getEntityEdgeWeightMap(moduleStateDb5) : /* @__PURE__ */ new Map();
6040
6222
  const scoredEntities = [];
6041
6223
  const directlyMatchedEntities = /* @__PURE__ */ new Set();
6042
6224
  const entitiesWithContentMatch = /* @__PURE__ */ new Set();
@@ -6052,6 +6234,12 @@ async function suggestRelatedLinks(content, options = {}) {
6052
6234
  if (linkedEntities.has(entityName.toLowerCase())) {
6053
6235
  continue;
6054
6236
  }
6237
+ if (moduleStateDb5 && !disabled.has("feedback")) {
6238
+ const noteFolder2 = notePath ? notePath.split("/").slice(0, -1).join("/") : void 0;
6239
+ if (isSuppressed(moduleStateDb5, entityName, noteFolder2)) {
6240
+ continue;
6241
+ }
6242
+ }
6055
6243
  const contentScore = disabled.has("exact_match") && disabled.has("stem_match") ? 0 : scoreEntity(entity, contentTokens, contentStems, config);
6056
6244
  let score = contentScore;
6057
6245
  if (contentScore > 0) {
@@ -6069,6 +6257,8 @@ async function suggestRelatedLinks(content, options = {}) {
6069
6257
  score += layerHubBoost;
6070
6258
  const layerFeedbackAdj = disabled.has("feedback") ? 0 : feedbackBoosts.get(entityName) ?? 0;
6071
6259
  score += layerFeedbackAdj;
6260
+ const layerEdgeWeightBoost = disabled.has("edge_weight") ? 0 : getEdgeWeightBoostScore(entityName, edgeWeightMap);
6261
+ score += layerEdgeWeightBoost;
6072
6262
  if (score > 0) {
6073
6263
  directlyMatchedEntities.add(entityName);
6074
6264
  }
@@ -6086,7 +6276,8 @@ async function suggestRelatedLinks(content, options = {}) {
6086
6276
  recencyBoost: layerRecencyBoost,
6087
6277
  crossFolderBoost: layerCrossFolderBoost,
6088
6278
  hubBoost: layerHubBoost,
6089
- feedbackAdjustment: layerFeedbackAdj
6279
+ feedbackAdjustment: layerFeedbackAdj,
6280
+ edgeWeightBoost: layerEdgeWeightBoost
6090
6281
  }
6091
6282
  });
6092
6283
  }
@@ -6098,6 +6289,10 @@ async function suggestRelatedLinks(content, options = {}) {
6098
6289
  if (!disabled.has("length_filter") && entityName.length > MAX_ENTITY_LENGTH) continue;
6099
6290
  if (!disabled.has("article_filter") && isLikelyArticleTitle(entityName)) continue;
6100
6291
  if (linkedEntities.has(entityName.toLowerCase())) continue;
6292
+ if (moduleStateDb5 && !disabled.has("feedback")) {
6293
+ const noteFolder2 = notePath ? notePath.split("/").slice(0, -1).join("/") : void 0;
6294
+ if (isSuppressed(moduleStateDb5, entityName, noteFolder2)) continue;
6295
+ }
6101
6296
  const boost = getCooccurrenceBoost(entityName, directlyMatchedEntities, cooccurrenceIndex, recencyIndex);
6102
6297
  if (boost > 0) {
6103
6298
  const existing = scoredEntities.find((e) => e.name === entityName);
@@ -6119,7 +6314,8 @@ async function suggestRelatedLinks(content, options = {}) {
6119
6314
  const crossFolderBoost = disabled.has("cross_folder") ? 0 : notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
6120
6315
  const hubBoost = disabled.has("hub_boost") ? 0 : getHubBoost(entity);
6121
6316
  const feedbackAdj = disabled.has("feedback") ? 0 : feedbackBoosts.get(entityName) ?? 0;
6122
- const totalBoost = boost + typeBoost + contextBoost + recencyBoostVal + crossFolderBoost + hubBoost + feedbackAdj;
6317
+ const edgeWeightBoost = disabled.has("edge_weight") ? 0 : getEdgeWeightBoostScore(entityName, edgeWeightMap);
6318
+ const totalBoost = boost + typeBoost + contextBoost + recencyBoostVal + crossFolderBoost + hubBoost + feedbackAdj + edgeWeightBoost;
6123
6319
  if (totalBoost >= adaptiveMinScore) {
6124
6320
  scoredEntities.push({
6125
6321
  name: entityName,
@@ -6134,7 +6330,8 @@ async function suggestRelatedLinks(content, options = {}) {
6134
6330
  recencyBoost: recencyBoostVal,
6135
6331
  crossFolderBoost,
6136
6332
  hubBoost,
6137
- feedbackAdjustment: feedbackAdj
6333
+ feedbackAdjustment: feedbackAdj,
6334
+ edgeWeightBoost
6138
6335
  }
6139
6336
  });
6140
6337
  }
@@ -6160,6 +6357,10 @@ async function suggestRelatedLinks(content, options = {}) {
6160
6357
  existing.score += boost;
6161
6358
  existing.breakdown.semanticBoost = boost;
6162
6359
  } else if (!linkedEntities.has(match.entityName.toLowerCase())) {
6360
+ if (moduleStateDb5 && !disabled.has("feedback")) {
6361
+ const noteFolder2 = notePath ? notePath.split("/").slice(0, -1).join("/") : void 0;
6362
+ if (isSuppressed(moduleStateDb5, match.entityName, noteFolder2)) continue;
6363
+ }
6163
6364
  const entityWithType = entitiesWithTypes.find(
6164
6365
  (et) => et.entity.name === match.entityName
6165
6366
  );
@@ -6172,7 +6373,8 @@ async function suggestRelatedLinks(content, options = {}) {
6172
6373
  const layerHubBoost = disabled.has("hub_boost") ? 0 : getHubBoost(entity);
6173
6374
  const layerCrossFolderBoost = disabled.has("cross_folder") ? 0 : notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
6174
6375
  const layerFeedbackAdj = disabled.has("feedback") ? 0 : feedbackBoosts.get(match.entityName) ?? 0;
6175
- const totalScore = boost + layerTypeBoost + layerContextBoost + layerHubBoost + layerCrossFolderBoost + layerFeedbackAdj;
6376
+ const layerEdgeWeightBoost = disabled.has("edge_weight") ? 0 : getEdgeWeightBoostScore(match.entityName, edgeWeightMap);
6377
+ const totalScore = boost + layerTypeBoost + layerContextBoost + layerHubBoost + layerCrossFolderBoost + layerFeedbackAdj + layerEdgeWeightBoost;
6176
6378
  if (totalScore >= adaptiveMinScore) {
6177
6379
  scoredEntities.push({
6178
6380
  name: match.entityName,
@@ -6188,7 +6390,8 @@ async function suggestRelatedLinks(content, options = {}) {
6188
6390
  crossFolderBoost: layerCrossFolderBoost,
6189
6391
  hubBoost: layerHubBoost,
6190
6392
  feedbackAdjustment: layerFeedbackAdj,
6191
- semanticBoost: boost
6393
+ semanticBoost: boost,
6394
+ edgeWeightBoost: layerEdgeWeightBoost
6192
6395
  }
6193
6396
  });
6194
6397
  entitiesWithContentMatch.add(match.entityName);
@@ -6213,15 +6416,15 @@ async function suggestRelatedLinks(content, options = {}) {
6213
6416
  }
6214
6417
  return 0;
6215
6418
  });
6216
- if (moduleStateDb4 && notePath) {
6419
+ if (moduleStateDb5 && notePath) {
6217
6420
  try {
6218
6421
  const now = Date.now();
6219
- const insertStmt = moduleStateDb4.db.prepare(`
6422
+ const insertStmt = moduleStateDb5.db.prepare(`
6220
6423
  INSERT OR IGNORE INTO suggestion_events
6221
6424
  (timestamp, note_path, entity, total_score, breakdown_json, threshold, passed, strictness, applied, pipeline_event_id)
6222
6425
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, NULL)
6223
6426
  `);
6224
- const persistTransaction = moduleStateDb4.db.transaction(() => {
6427
+ const persistTransaction = moduleStateDb5.db.transaction(() => {
6225
6428
  for (const e of relevantEntities) {
6226
6429
  insertStmt.run(
6227
6430
  now,
@@ -6262,13 +6465,15 @@ async function suggestRelatedLinks(content, options = {}) {
6262
6465
  if (topSuggestions.length === 0) {
6263
6466
  return emptyResult;
6264
6467
  }
6265
- const suffix = "\u2192 " + topSuggestions.map((name) => `[[${name}]]`).join(", ");
6468
+ const MIN_SUFFIX_SCORE = 12;
6469
+ const suffixEntries = topEntries.filter((e) => e.score >= MIN_SUFFIX_SCORE);
6470
+ const suffix = suffixEntries.length > 0 ? "\u2192 " + suffixEntries.map((e) => `[[${e.name}]]`).join(", ") : "";
6266
6471
  const result = {
6267
6472
  suggestions: topSuggestions,
6268
6473
  suffix
6269
6474
  };
6270
6475
  if (detail) {
6271
- const feedbackStats = moduleStateDb4 ? getEntityStats(moduleStateDb4) : [];
6476
+ const feedbackStats = moduleStateDb5 ? getEntityStats(moduleStateDb5) : [];
6272
6477
  const feedbackMap = new Map(feedbackStats.map((s) => [s.entity, s]));
6273
6478
  result.detailed = topEntries.map((e) => {
6274
6479
  const fb = feedbackMap.get(e.name);
@@ -6287,9 +6492,9 @@ async function suggestRelatedLinks(content, options = {}) {
6287
6492
  return result;
6288
6493
  }
6289
6494
  function detectAliasCollisions(noteName, aliases = []) {
6290
- if (!moduleStateDb4) return [];
6495
+ if (!moduleStateDb5) return [];
6291
6496
  const collisions = [];
6292
- const nameAsAlias = getEntitiesByAlias(moduleStateDb4, noteName);
6497
+ const nameAsAlias = getEntitiesByAlias(moduleStateDb5, noteName);
6293
6498
  for (const entity of nameAsAlias) {
6294
6499
  if (entity.name.toLowerCase() === noteName.toLowerCase()) continue;
6295
6500
  collisions.push({
@@ -6303,7 +6508,7 @@ function detectAliasCollisions(noteName, aliases = []) {
6303
6508
  });
6304
6509
  }
6305
6510
  for (const alias of aliases) {
6306
- const existingByName = getEntityByName(moduleStateDb4, alias);
6511
+ const existingByName = getEntityByName(moduleStateDb5, alias);
6307
6512
  if (existingByName && existingByName.name.toLowerCase() !== noteName.toLowerCase()) {
6308
6513
  collisions.push({
6309
6514
  term: alias,
@@ -6315,7 +6520,7 @@ function detectAliasCollisions(noteName, aliases = []) {
6315
6520
  }
6316
6521
  });
6317
6522
  }
6318
- const existingByAlias = getEntitiesByAlias(moduleStateDb4, alias);
6523
+ const existingByAlias = getEntitiesByAlias(moduleStateDb5, alias);
6319
6524
  for (const entity of existingByAlias) {
6320
6525
  if (entity.name.toLowerCase() === noteName.toLowerCase()) continue;
6321
6526
  if (existingByName && existingByName.name.toLowerCase() === entity.name.toLowerCase()) continue;
@@ -6339,8 +6544,8 @@ function suggestAliases(noteName, existingAliases = [], category) {
6339
6544
  function isSafe(alias) {
6340
6545
  if (existingLower.has(alias.toLowerCase())) return false;
6341
6546
  if (alias.toLowerCase() === noteName.toLowerCase()) return false;
6342
- if (!moduleStateDb4) return true;
6343
- const existing = getEntityByName(moduleStateDb4, alias);
6547
+ if (!moduleStateDb5) return true;
6548
+ const existing = getEntityByName(moduleStateDb5, alias);
6344
6549
  return !existing;
6345
6550
  }
6346
6551
  const inferredCategory = category || inferCategoryFromName(noteName);
@@ -6382,8 +6587,8 @@ function inferCategoryFromName(name) {
6382
6587
  }
6383
6588
  async function checkPreflightSimilarity(noteName) {
6384
6589
  const result = { similarEntities: [] };
6385
- if (!moduleStateDb4) return result;
6386
- const exact = getEntityByName(moduleStateDb4, noteName);
6590
+ if (!moduleStateDb5) return result;
6591
+ const exact = getEntityByName(moduleStateDb5, noteName);
6387
6592
  if (exact) {
6388
6593
  result.existingEntity = {
6389
6594
  name: exact.name,
@@ -6393,7 +6598,7 @@ async function checkPreflightSimilarity(noteName) {
6393
6598
  }
6394
6599
  const ftsNames = /* @__PURE__ */ new Set();
6395
6600
  try {
6396
- const searchResults = searchEntitiesDb(moduleStateDb4, noteName, 5);
6601
+ const searchResults = searchEntitiesDb(moduleStateDb5, noteName, 5);
6397
6602
  for (const sr of searchResults) {
6398
6603
  if (sr.name.toLowerCase() === noteName.toLowerCase()) continue;
6399
6604
  ftsNames.add(sr.name.toLowerCase());
@@ -6414,7 +6619,7 @@ async function checkPreflightSimilarity(noteName) {
6414
6619
  if (match.similarity < 0.85) continue;
6415
6620
  if (match.entityName.toLowerCase() === noteName.toLowerCase()) continue;
6416
6621
  if (ftsNames.has(match.entityName.toLowerCase())) continue;
6417
- const entity = getEntityByName(moduleStateDb4, match.entityName);
6622
+ const entity = getEntityByName(moduleStateDb5, match.entityName);
6418
6623
  if (entity) {
6419
6624
  result.similarEntities.push({
6420
6625
  name: entity.name,
@@ -9202,123 +9407,6 @@ function suggestEntityAliases(stateDb2, folder) {
9202
9407
  return suggestions;
9203
9408
  }
9204
9409
 
9205
- // src/core/write/edgeWeights.ts
9206
- var moduleStateDb5 = null;
9207
- function setEdgeWeightStateDb(stateDb2) {
9208
- moduleStateDb5 = stateDb2;
9209
- }
9210
- function buildPathToTargetsMap(stateDb2) {
9211
- const map = /* @__PURE__ */ new Map();
9212
- const rows = stateDb2.db.prepare(
9213
- "SELECT path, name_lower, aliases_json FROM entities"
9214
- ).all();
9215
- for (const row of rows) {
9216
- const targets = /* @__PURE__ */ new Set();
9217
- targets.add(row.name_lower);
9218
- if (row.aliases_json) {
9219
- try {
9220
- const aliases = JSON.parse(row.aliases_json);
9221
- for (const alias of aliases) {
9222
- targets.add(alias.toLowerCase());
9223
- }
9224
- } catch {
9225
- }
9226
- }
9227
- map.set(row.path, targets);
9228
- }
9229
- return map;
9230
- }
9231
- function pathToFallbackTarget(filePath) {
9232
- return filePath.replace(/\.md$/, "").split("/").pop()?.toLowerCase() ?? filePath.toLowerCase();
9233
- }
9234
- function recomputeEdgeWeights(stateDb2) {
9235
- const start = Date.now();
9236
- const edges = stateDb2.db.prepare(
9237
- "SELECT note_path, target FROM note_links"
9238
- ).all();
9239
- if (edges.length === 0) {
9240
- return { edges_updated: 0, duration_ms: Date.now() - start };
9241
- }
9242
- const survivalMap = /* @__PURE__ */ new Map();
9243
- const historyRows = stateDb2.db.prepare(
9244
- "SELECT note_path, target, edits_survived FROM note_link_history"
9245
- ).all();
9246
- for (const row of historyRows) {
9247
- survivalMap.set(`${row.note_path}\0${row.target}`, row.edits_survived);
9248
- }
9249
- const pathToTargets = buildPathToTargetsMap(stateDb2);
9250
- const targetToPaths = /* @__PURE__ */ new Map();
9251
- for (const [entityPath, targets] of pathToTargets) {
9252
- for (const target of targets) {
9253
- let paths = targetToPaths.get(target);
9254
- if (!paths) {
9255
- paths = /* @__PURE__ */ new Set();
9256
- targetToPaths.set(target, paths);
9257
- }
9258
- paths.add(entityPath);
9259
- }
9260
- }
9261
- const sessionRows = stateDb2.db.prepare(
9262
- `SELECT session_id, note_paths FROM tool_invocations
9263
- WHERE note_paths IS NOT NULL AND note_paths != '[]'`
9264
- ).all();
9265
- const sessionPaths = /* @__PURE__ */ new Map();
9266
- for (const row of sessionRows) {
9267
- try {
9268
- const paths = JSON.parse(row.note_paths);
9269
- if (!Array.isArray(paths) || paths.length === 0) continue;
9270
- let existing = sessionPaths.get(row.session_id);
9271
- if (!existing) {
9272
- existing = /* @__PURE__ */ new Set();
9273
- sessionPaths.set(row.session_id, existing);
9274
- }
9275
- for (const p of paths) {
9276
- existing.add(p);
9277
- }
9278
- } catch {
9279
- }
9280
- }
9281
- const coSessionCount = /* @__PURE__ */ new Map();
9282
- const sourceActivityCount = /* @__PURE__ */ new Map();
9283
- for (const [, paths] of sessionPaths) {
9284
- const sessionTargets = /* @__PURE__ */ new Set();
9285
- for (const p of paths) {
9286
- const targets = pathToTargets.get(p);
9287
- if (targets) {
9288
- for (const t of targets) sessionTargets.add(t);
9289
- } else {
9290
- sessionTargets.add(pathToFallbackTarget(p));
9291
- }
9292
- }
9293
- for (const edge of edges) {
9294
- if (paths.has(edge.note_path)) {
9295
- const srcKey = edge.note_path;
9296
- sourceActivityCount.set(srcKey, (sourceActivityCount.get(srcKey) ?? 0) + 1);
9297
- if (sessionTargets.has(edge.target)) {
9298
- const edgeKey = `${edge.note_path}\0${edge.target}`;
9299
- coSessionCount.set(edgeKey, (coSessionCount.get(edgeKey) ?? 0) + 1);
9300
- }
9301
- }
9302
- }
9303
- }
9304
- const now = Date.now();
9305
- const update = stateDb2.db.prepare(
9306
- "UPDATE note_links SET weight = ?, weight_updated_at = ? WHERE note_path = ? AND target = ?"
9307
- );
9308
- const tx = stateDb2.db.transaction(() => {
9309
- for (const edge of edges) {
9310
- const edgeKey = `${edge.note_path}\0${edge.target}`;
9311
- const editsSurvived = survivalMap.get(edgeKey) ?? 0;
9312
- const coSessions = coSessionCount.get(edgeKey) ?? 0;
9313
- const sourceAccess = sourceActivityCount.get(edge.note_path) ?? 0;
9314
- const weight = 1 + editsSurvived * 0.5 + Math.min(coSessions * 0.5, 3) + Math.min(sourceAccess * 0.2, 2);
9315
- update.run(Math.round(weight * 1e3) / 1e3, now, edge.note_path, edge.target);
9316
- }
9317
- });
9318
- tx();
9319
- return { edges_updated: edges.length, duration_ms: Date.now() - start };
9320
- }
9321
-
9322
9410
  // src/tools/read/system.ts
9323
9411
  function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfig, getStateDb) {
9324
9412
  const RefreshIndexOutputSchema = {
@@ -12852,7 +12940,7 @@ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", con
12852
12940
  wikilinkInfo: wikilinkInfo || "none"
12853
12941
  };
12854
12942
  let suggestInfo;
12855
- if (suggestOutgoingLinks && !skipWikilinks) {
12943
+ if (suggestOutgoingLinks && !skipWikilinks && processedContent.length >= 100) {
12856
12944
  const result = await suggestRelatedLinks(processedContent, { maxSuggestions, notePath });
12857
12945
  if (result.suffix) {
12858
12946
  processedContent = processedContent + " " + result.suffix;
@@ -12971,7 +13059,7 @@ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", con
12971
13059
  }
12972
13060
  let workingReplacement = validationResult.content;
12973
13061
  let { content: processedReplacement } = maybeApplyWikilinks(workingReplacement, skipWikilinks, notePath);
12974
- if (suggestOutgoingLinks && !skipWikilinks) {
13062
+ if (suggestOutgoingLinks && !skipWikilinks && processedReplacement.length >= 100) {
12975
13063
  const result = await suggestRelatedLinks(processedReplacement, { maxSuggestions, notePath });
12976
13064
  if (result.suffix) {
12977
13065
  processedReplacement = processedReplacement + " " + result.suffix;
@@ -13352,7 +13440,7 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
13352
13440
  }
13353
13441
  let { content: processedContent, wikilinkInfo } = maybeApplyWikilinks(effectiveContent, skipWikilinks, notePath);
13354
13442
  let suggestInfo;
13355
- if (suggestOutgoingLinks && !skipWikilinks) {
13443
+ if (suggestOutgoingLinks && !skipWikilinks && processedContent.length >= 100) {
13356
13444
  const result = await suggestRelatedLinks(processedContent, { maxSuggestions, notePath });
13357
13445
  if (result.suffix) {
13358
13446
  processedContent = processedContent + " " + result.suffix;
@@ -15774,10 +15862,11 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
15774
15862
  days_back: z21.number().optional().describe("Days to look back (default: 30)"),
15775
15863
  granularity: z21.enum(["day", "week"]).optional().describe("Time bucket granularity for layer_timeseries (default: day)"),
15776
15864
  timestamp_before: z21.number().optional().describe("Earlier timestamp for snapshot_diff"),
15777
- timestamp_after: z21.number().optional().describe("Later timestamp for snapshot_diff")
15865
+ timestamp_after: z21.number().optional().describe("Later timestamp for snapshot_diff"),
15866
+ skip_status_update: z21.boolean().optional().describe("Skip marking application as removed (caller will trigger implicit detection via file edit)")
15778
15867
  }
15779
15868
  },
15780
- async ({ mode, entity, note_path, context, correct, limit, days_back, granularity, timestamp_before, timestamp_after }) => {
15869
+ async ({ mode, entity, note_path, context, correct, limit, days_back, granularity, timestamp_before, timestamp_after, skip_status_update }) => {
15781
15870
  const stateDb2 = getStateDb();
15782
15871
  if (!stateDb2) {
15783
15872
  return {
@@ -15805,6 +15894,11 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
15805
15894
  isError: true
15806
15895
  };
15807
15896
  }
15897
+ if (!correct && note_path && !skip_status_update) {
15898
+ stateDb2.db.prepare(
15899
+ `UPDATE wikilink_applications SET status = 'removed' WHERE entity = ? AND note_path = ? COLLATE NOCASE`
15900
+ ).run(entity, note_path);
15901
+ }
15808
15902
  const suppressionUpdated = updateSuppressionList(stateDb2) > 0;
15809
15903
  result = {
15810
15904
  mode: "report",
@@ -17829,7 +17923,15 @@ async function runPostIndexWork(index) {
17829
17923
  if (edgeWeightAgeMs >= 60 * 60 * 1e3) {
17830
17924
  const result = recomputeEdgeWeights(stateDb);
17831
17925
  lastEdgeWeightRebuildAt = Date.now();
17832
- tracker.end({ rebuilt: true, edges: result.edges_updated, duration_ms: result.duration_ms });
17926
+ tracker.end({
17927
+ rebuilt: true,
17928
+ edges: result.edges_updated,
17929
+ duration_ms: result.duration_ms,
17930
+ total_weighted: result.total_weighted,
17931
+ avg_weight: result.avg_weight,
17932
+ strong_count: result.strong_count,
17933
+ top_changes: result.top_changes
17934
+ });
17833
17935
  serverLog("watcher", `Edge weights: ${result.edges_updated} edges in ${result.duration_ms}ms`);
17834
17936
  } else {
17835
17937
  tracker.end({ rebuilt: false, age_ms: edgeWeightAgeMs });
@@ -18114,9 +18216,27 @@ async function runPostIndexWork(index) {
18114
18216
  }
18115
18217
  }
18116
18218
  }
18117
- tracker.end({ removals: feedbackResults });
18118
- if (feedbackResults.length > 0) {
18119
- serverLog("watcher", `Implicit feedback: ${feedbackResults.length} removals detected`);
18219
+ const additionResults = [];
18220
+ if (stateDb && linkDiffs.length > 0) {
18221
+ const checkApplication = stateDb.db.prepare(
18222
+ `SELECT 1 FROM wikilink_applications WHERE LOWER(entity) = LOWER(?) AND note_path = ? AND status = 'applied'`
18223
+ );
18224
+ for (const diff of linkDiffs) {
18225
+ for (const target of diff.added) {
18226
+ if (checkApplication.get(target, diff.file)) continue;
18227
+ const entity = entitiesAfter.find(
18228
+ (e) => e.nameLower === target || (e.aliases ?? []).some((a) => a.toLowerCase() === target)
18229
+ );
18230
+ if (entity) {
18231
+ recordFeedback(stateDb, entity.name, "implicit:manual_added", diff.file, true);
18232
+ additionResults.push({ entity: entity.name, file: diff.file });
18233
+ }
18234
+ }
18235
+ }
18236
+ }
18237
+ tracker.end({ removals: feedbackResults, additions: additionResults });
18238
+ if (feedbackResults.length > 0 || additionResults.length > 0) {
18239
+ serverLog("watcher", `Implicit feedback: ${feedbackResults.length} removals, ${additionResults.length} manual additions detected`);
18120
18240
  }
18121
18241
  tracker.start("tag_scan", { files: filteredEvents.length });
18122
18242
  const tagDiffs = [];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.0.42",
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.",
3
+ "version": "2.0.44",
4
+ "description": "MCP server that gives Claude full read/write access to your Obsidian vault. Select from 42 tools for search, backlinks, graph queries, mutations, and hybrid semantic search.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@modelcontextprotocol/sdk": "^1.25.1",
55
- "@velvetmonkey/vault-core": "^2.0.42",
55
+ "@velvetmonkey/vault-core": "^2.0.44",
56
56
  "better-sqlite3": "^11.0.0",
57
57
  "chokidar": "^4.0.0",
58
58
  "gray-matter": "^4.0.3",