@velvetmonkey/flywheel-memory 2.0.39 → 2.0.41

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 +329 -13
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -3506,9 +3506,11 @@ var FEEDBACK_BOOST_TIERS = [
3506
3506
  ];
3507
3507
  function recordFeedback(stateDb2, entity, context, notePath, correct) {
3508
3508
  try {
3509
- stateDb2.db.prepare(
3509
+ console.error(`[Flywheel] recordFeedback: entity="${entity}" context="${context}" notePath="${notePath}" correct=${correct}`);
3510
+ const result = stateDb2.db.prepare(
3510
3511
  "INSERT INTO wikilink_feedback (entity, context, note_path, correct) VALUES (?, ?, ?, ?)"
3511
3512
  ).run(entity, context, notePath, correct ? 1 : 0);
3513
+ console.error(`[Flywheel] recordFeedback: inserted id=${result.lastInsertRowid}`);
3512
3514
  } catch (e) {
3513
3515
  console.error(`[Flywheel] recordFeedback failed for entity="${entity}": ${e}`);
3514
3516
  throw e;
@@ -3719,13 +3721,25 @@ function getStoredNoteLinks(stateDb2, notePath) {
3719
3721
  return new Set(rows.map((r) => r.target));
3720
3722
  }
3721
3723
  function updateStoredNoteLinks(stateDb2, notePath, currentLinks) {
3722
- const del = stateDb2.db.prepare("DELETE FROM note_links WHERE note_path = ?");
3723
- const ins = stateDb2.db.prepare("INSERT INTO note_links (note_path, target) VALUES (?, ?)");
3724
+ const ins = stateDb2.db.prepare(
3725
+ "INSERT OR IGNORE INTO note_links (note_path, target) VALUES (?, ?)"
3726
+ );
3727
+ const del = stateDb2.db.prepare(
3728
+ "DELETE FROM note_links WHERE note_path = ? AND target = ?"
3729
+ );
3730
+ const existing = stateDb2.db.prepare(
3731
+ "SELECT target FROM note_links WHERE note_path = ?"
3732
+ );
3724
3733
  const tx = stateDb2.db.transaction(() => {
3725
- del.run(notePath);
3726
3734
  for (const target of currentLinks) {
3727
3735
  ins.run(notePath, target);
3728
3736
  }
3737
+ const rows = existing.all(notePath);
3738
+ for (const row of rows) {
3739
+ if (!currentLinks.has(row.target)) {
3740
+ del.run(notePath, row.target);
3741
+ }
3742
+ }
3729
3743
  });
3730
3744
  tx();
3731
3745
  }
@@ -5387,6 +5401,9 @@ function getEffectiveStrictness(notePath) {
5387
5401
  function getCooccurrenceIndex() {
5388
5402
  return cooccurrenceIndex;
5389
5403
  }
5404
+ function setCooccurrenceIndex(index) {
5405
+ cooccurrenceIndex = index;
5406
+ }
5390
5407
  var entityIndex = null;
5391
5408
  var indexReady = false;
5392
5409
  var indexError2 = null;
@@ -5502,6 +5519,11 @@ function checkAndRefreshIfStale() {
5502
5519
  console.error(`[Flywheel] Reloaded ${dbIndex._metadata.total_entities} entities`);
5503
5520
  }
5504
5521
  }
5522
+ const freshRecency = loadRecencyFromStateDb();
5523
+ if (freshRecency && freshRecency.lastUpdated > (recencyIndex?.lastUpdated ?? 0)) {
5524
+ recencyIndex = freshRecency;
5525
+ console.error(`[Flywheel] Refreshed recency index (${freshRecency.lastMentioned.size} entities)`);
5526
+ }
5505
5527
  } catch (e) {
5506
5528
  console.error("[Flywheel] Failed to check for stale entities:", e);
5507
5529
  }
@@ -7305,7 +7327,7 @@ var GetBacklinksOutputSchema = {
7305
7327
  returned_count: z.coerce.number().describe("Number of backlinks returned (may be limited)"),
7306
7328
  backlinks: z.array(BacklinkItemSchema).describe("List of backlinks")
7307
7329
  };
7308
- function registerGraphTools(server2, getIndex, getVaultPath) {
7330
+ function registerGraphTools(server2, getIndex, getVaultPath, getStateDb) {
7309
7331
  server2.registerTool(
7310
7332
  "get_backlinks",
7311
7333
  {
@@ -7427,6 +7449,89 @@ function registerGraphTools(server2, getIndex, getVaultPath) {
7427
7449
  };
7428
7450
  }
7429
7451
  );
7452
+ server2.tool(
7453
+ "get_weighted_links",
7454
+ "Get outgoing links from a note ranked by edge weight. Weights reflect link survival, co-session access, and source activity. Time decay is applied at query time.",
7455
+ {
7456
+ path: z.string().describe('Path to the note (e.g., "daily/2026-02-24.md")'),
7457
+ min_weight: z.number().default(0).describe("Minimum weight threshold (default 0 = all links)"),
7458
+ limit: z.number().default(20).describe("Maximum number of results to return")
7459
+ },
7460
+ async ({ path: notePath, min_weight, limit: requestedLimit }) => {
7461
+ const stateDb2 = getStateDb?.();
7462
+ if (!stateDb2) {
7463
+ return { content: [{ type: "text", text: JSON.stringify({ error: "StateDb not initialized" }) }] };
7464
+ }
7465
+ const limit = Math.min(requestedLimit ?? 20, MAX_LIMIT);
7466
+ const now = Date.now();
7467
+ const rows = stateDb2.db.prepare(`
7468
+ SELECT target, weight, weight_updated_at
7469
+ FROM note_links
7470
+ WHERE note_path = ?
7471
+ ORDER BY weight DESC
7472
+ `).all(notePath);
7473
+ const results = rows.map((row) => {
7474
+ const daysSinceUpdated = row.weight_updated_at ? (now - row.weight_updated_at) / (1e3 * 60 * 60 * 24) : 0;
7475
+ const decayFactor = Math.max(0.1, 1 - daysSinceUpdated / 180);
7476
+ const effectiveWeight = Math.round(row.weight * decayFactor * 1e3) / 1e3;
7477
+ return {
7478
+ target: row.target,
7479
+ weight: row.weight,
7480
+ weight_effective: effectiveWeight,
7481
+ last_updated: row.weight_updated_at
7482
+ };
7483
+ }).filter((r) => r.weight_effective >= min_weight).slice(0, limit);
7484
+ return {
7485
+ content: [{
7486
+ type: "text",
7487
+ text: JSON.stringify({
7488
+ note: notePath,
7489
+ count: results.length,
7490
+ links: results
7491
+ }, null, 2)
7492
+ }]
7493
+ };
7494
+ }
7495
+ );
7496
+ server2.tool(
7497
+ "get_strong_connections",
7498
+ "Get bidirectional connections for a note ranked by combined edge weight. Returns both outgoing and incoming links.",
7499
+ {
7500
+ path: z.string().describe('Path to the note (e.g., "daily/2026-02-24.md")'),
7501
+ limit: z.number().default(20).describe("Maximum number of results to return")
7502
+ },
7503
+ async ({ path: notePath, limit: requestedLimit }) => {
7504
+ const stateDb2 = getStateDb?.();
7505
+ if (!stateDb2) {
7506
+ return { content: [{ type: "text", text: JSON.stringify({ error: "StateDb not initialized" }) }] };
7507
+ }
7508
+ const limit = Math.min(requestedLimit ?? 20, MAX_LIMIT);
7509
+ const stem2 = notePath.replace(/\.md$/, "").split("/").pop()?.toLowerCase() ?? "";
7510
+ const rows = stateDb2.db.prepare(`
7511
+ SELECT target AS node, weight, 'outgoing' AS direction
7512
+ FROM note_links WHERE note_path = ?
7513
+ UNION ALL
7514
+ SELECT note_path AS node, weight, 'incoming' AS direction
7515
+ FROM note_links WHERE target = ?
7516
+ ORDER BY weight DESC
7517
+ LIMIT ?
7518
+ `).all(notePath, stem2, limit);
7519
+ return {
7520
+ content: [{
7521
+ type: "text",
7522
+ text: JSON.stringify({
7523
+ note: notePath,
7524
+ count: rows.length,
7525
+ connections: rows.map((r) => ({
7526
+ node: r.node,
7527
+ weight: r.weight,
7528
+ direction: r.direction
7529
+ }))
7530
+ }, null, 2)
7531
+ }]
7532
+ };
7533
+ }
7534
+ );
7430
7535
  }
7431
7536
 
7432
7537
  // src/tools/read/wikilinks.ts
@@ -8817,9 +8922,11 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
8817
8922
  // Entity options (used with scope "entities")
8818
8923
  prefix: z4.boolean().default(false).describe("Enable prefix matching for entity search (autocomplete)"),
8819
8924
  // Pagination
8820
- limit: z4.number().default(20).describe("Maximum number of results to return")
8925
+ limit: z4.number().default(20).describe("Maximum number of results to return"),
8926
+ // Context boost (edge weights)
8927
+ context_note: z4.string().optional().describe("Path of the note providing context. When set, results connected to this note via weighted edges get an RRF boost.")
8821
8928
  },
8822
- async ({ query, scope, where, has_tag, has_any_tag, has_all_tags, include_children, folder, title_contains, modified_after, modified_before, sort_by, order, prefix, limit: requestedLimit }) => {
8929
+ async ({ query, scope, where, has_tag, has_any_tag, has_all_tags, include_children, folder, title_contains, modified_after, modified_before, sort_by, order, prefix, limit: requestedLimit, context_note }) => {
8823
8930
  const limit = Math.min(requestedLimit ?? 20, MAX_LIMIT);
8824
8931
  const index = getIndex();
8825
8932
  const vaultPath2 = getVaultPath();
@@ -8930,17 +9037,47 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
8930
9037
  }
8931
9038
  }
8932
9039
  }
9040
+ let edgeRanked = [];
9041
+ if (context_note) {
9042
+ const ctxStateDb = getStateDb();
9043
+ if (ctxStateDb) {
9044
+ try {
9045
+ const edgeRows = ctxStateDb.db.prepare(`
9046
+ SELECT nl.target, nl.weight FROM note_links nl
9047
+ WHERE nl.note_path = ? AND nl.weight > 1.0
9048
+ ORDER BY nl.weight DESC LIMIT ?
9049
+ `).all(context_note, limit);
9050
+ if (edgeRows.length > 0) {
9051
+ const entityRows = ctxStateDb.db.prepare(
9052
+ "SELECT path, name_lower FROM entities"
9053
+ ).all();
9054
+ const targetToPath = /* @__PURE__ */ new Map();
9055
+ for (const e of entityRows) {
9056
+ targetToPath.set(e.name_lower, e.path);
9057
+ }
9058
+ edgeRanked = edgeRows.map((r) => {
9059
+ const entityPath = targetToPath.get(r.target);
9060
+ return entityPath ? { path: entityPath, title: r.target } : null;
9061
+ }).filter((r) => r !== null);
9062
+ }
9063
+ } catch {
9064
+ }
9065
+ }
9066
+ }
8933
9067
  if (hasEmbeddingsIndex()) {
8934
9068
  try {
8935
9069
  const semanticResults = await semanticSearch(query, limit);
8936
9070
  const fts5Ranked = fts5Results.map((r) => ({ path: r.path, title: r.title, snippet: r.snippet }));
8937
9071
  const semanticRanked = semanticResults.map((r) => ({ path: r.path, title: r.title }));
8938
- const entityRanked = entityResults.map((r) => ({ path: r.path, title: r.name }));
8939
- const rrfScores = reciprocalRankFusion(fts5Ranked, semanticRanked, entityRanked);
9072
+ const entityRankedList = entityResults.map((r) => ({ path: r.path, title: r.name }));
9073
+ const rrfLists = [fts5Ranked, semanticRanked, entityRankedList];
9074
+ if (edgeRanked.length > 0) rrfLists.push(edgeRanked);
9075
+ const rrfScores = reciprocalRankFusion(...rrfLists);
8940
9076
  const allPaths = /* @__PURE__ */ new Set([
8941
9077
  ...fts5Results.map((r) => r.path),
8942
9078
  ...semanticResults.map((r) => r.path),
8943
- ...entityResults.map((r) => r.path)
9079
+ ...entityResults.map((r) => r.path),
9080
+ ...edgeRanked.map((r) => r.path)
8944
9081
  ]);
8945
9082
  const fts5Map = new Map(fts5Results.map((r) => [r.path, r]));
8946
9083
  const semanticMap = new Map(semanticResults.map((r) => [r.path, r]));
@@ -9065,6 +9202,123 @@ function suggestEntityAliases(stateDb2, folder) {
9065
9202
  return suggestions;
9066
9203
  }
9067
9204
 
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
+
9068
9322
  // src/tools/read/system.ts
9069
9323
  function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfig, getStateDb) {
9070
9324
  const RefreshIndexOutputSchema = {
@@ -9072,6 +9326,7 @@ function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfi
9072
9326
  notes_count: z5.number().describe("Number of notes indexed"),
9073
9327
  entities_count: z5.number().describe("Number of entities (titles + aliases)"),
9074
9328
  fts5_notes: z5.number().describe("Number of notes in FTS5 search index"),
9329
+ edges_recomputed: z5.number().optional().describe("Number of edges with recomputed weights"),
9075
9330
  duration_ms: z5.number().describe("Time taken to rebuild index")
9076
9331
  };
9077
9332
  server2.registerTool(
@@ -9139,6 +9394,16 @@ function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfi
9139
9394
  } catch (err) {
9140
9395
  console.error("[Flywheel] FTS5 rebuild failed:", err);
9141
9396
  }
9397
+ let edgesRecomputed = 0;
9398
+ if (stateDb2) {
9399
+ try {
9400
+ const edgeResult = recomputeEdgeWeights(stateDb2);
9401
+ edgesRecomputed = edgeResult.edges_updated;
9402
+ console.error(`[Flywheel] Edge weights: ${edgeResult.edges_updated} edges in ${edgeResult.duration_ms}ms`);
9403
+ } catch (err) {
9404
+ console.error("[Flywheel] Edge weight recompute failed:", err);
9405
+ }
9406
+ }
9142
9407
  const duration = Date.now() - startTime;
9143
9408
  if (stateDb2) {
9144
9409
  recordIndexEvent(stateDb2, {
@@ -9152,6 +9417,7 @@ function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfi
9152
9417
  notes_count: newIndex.notes.size,
9153
9418
  entities_count: newIndex.entities.size,
9154
9419
  fts5_notes: fts5Notes,
9420
+ edges_recomputed: edgesRecomputed,
9155
9421
  duration_ms: duration
9156
9422
  };
9157
9423
  return {
@@ -12164,6 +12430,13 @@ function normalizeInput(content, format) {
12164
12430
  normalized = trimmed;
12165
12431
  changes.push("Trimmed excessive blank lines");
12166
12432
  }
12433
+ const multiLineWikilink = /\[\[([^\]]*\n[^\]]*)\]\]/g;
12434
+ if (multiLineWikilink.test(normalized)) {
12435
+ normalized = normalized.replace(multiLineWikilink, (_match, inner) => {
12436
+ return "[[" + inner.replace(/\s*\n\s*/g, " ").trim() + "]]";
12437
+ });
12438
+ changes.push("Fixed multi-line wikilinks");
12439
+ }
12167
12440
  return {
12168
12441
  content: normalized,
12169
12442
  normalized: changes.length > 0,
@@ -15413,9 +15686,11 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
15413
15686
  let result;
15414
15687
  switch (mode) {
15415
15688
  case "report": {
15689
+ console.error(`[Flywheel] wikilink_feedback report: entity="${entity}" correct=${JSON.stringify(correct)} (type: ${typeof correct})`);
15416
15690
  if (!entity || correct === void 0) {
15417
15691
  return {
15418
- content: [{ type: "text", text: JSON.stringify({ error: "entity and correct are required for report mode" }) }]
15692
+ content: [{ type: "text", text: JSON.stringify({ error: "entity and correct are required for report mode" }) }],
15693
+ isError: true
15419
15694
  };
15420
15695
  }
15421
15696
  try {
@@ -15424,7 +15699,8 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
15424
15699
  return {
15425
15700
  content: [{ type: "text", text: JSON.stringify({
15426
15701
  error: `Failed to record feedback: ${e instanceof Error ? e.message : String(e)}`
15427
- }) }]
15702
+ }) }],
15703
+ isError: true
15428
15704
  };
15429
15705
  }
15430
15706
  const suppressionUpdated = updateSuppressionList(stateDb2) > 0;
@@ -16933,7 +17209,7 @@ registerSystemTools(
16933
17209
  },
16934
17210
  () => stateDb
16935
17211
  );
16936
- registerGraphTools(server, () => vaultIndex, () => vaultPath);
17212
+ registerGraphTools(server, () => vaultIndex, () => vaultPath, () => stateDb);
16937
17213
  registerWikilinkTools(server, () => vaultIndex, () => vaultPath);
16938
17214
  registerQueryTools(server, () => vaultIndex, () => vaultPath, () => stateDb);
16939
17215
  registerPrimitiveTools(server, () => vaultIndex, () => vaultPath, () => flywheelConfig);
@@ -16987,6 +17263,7 @@ async function main() {
16987
17263
  loadEntityEmbeddingsToMemory();
16988
17264
  setWriteStateDb(stateDb);
16989
17265
  setRecencyStateDb(stateDb);
17266
+ setEdgeWeightStateDb(stateDb);
16990
17267
  } catch (err) {
16991
17268
  const msg = err instanceof Error ? err.message : String(err);
16992
17269
  serverLog("statedb", `StateDb initialization failed: ${msg}`, "error");
@@ -17083,6 +17360,8 @@ async function main() {
17083
17360
  }
17084
17361
  }
17085
17362
  var DEFAULT_ENTITY_EXCLUDE_FOLDERS = ["node_modules", "templates", "attachments", "tmp"];
17363
+ var lastCooccurrenceRebuildAt = 0;
17364
+ var lastEdgeWeightRebuildAt = 0;
17086
17365
  async function updateEntitiesInStateDb() {
17087
17366
  if (!stateDb) return;
17088
17367
  try {
@@ -17423,6 +17702,42 @@ async function runPostIndexWork(index) {
17423
17702
  tracker.end({ error: String(e) });
17424
17703
  serverLog("watcher", `Recency: failed: ${e}`);
17425
17704
  }
17705
+ tracker.start("cooccurrence", { entity_count: entitiesAfter.length });
17706
+ try {
17707
+ const cooccurrenceAgeMs = lastCooccurrenceRebuildAt > 0 ? Date.now() - lastCooccurrenceRebuildAt : Infinity;
17708
+ if (cooccurrenceAgeMs >= 60 * 60 * 1e3) {
17709
+ const entityNames = entitiesAfter.map((e) => e.name);
17710
+ const cooccurrenceIdx = await mineCooccurrences(vaultPath, entityNames);
17711
+ setCooccurrenceIndex(cooccurrenceIdx);
17712
+ lastCooccurrenceRebuildAt = Date.now();
17713
+ tracker.end({ rebuilt: true, associations: cooccurrenceIdx._metadata.total_associations });
17714
+ serverLog("watcher", `Co-occurrence: rebuilt ${cooccurrenceIdx._metadata.total_associations} associations`);
17715
+ } else {
17716
+ tracker.end({ rebuilt: false, age_ms: cooccurrenceAgeMs });
17717
+ serverLog("watcher", `Co-occurrence: cache valid (${Math.round(cooccurrenceAgeMs / 1e3)}s old)`);
17718
+ }
17719
+ } catch (e) {
17720
+ tracker.end({ error: String(e) });
17721
+ serverLog("watcher", `Co-occurrence: failed: ${e}`);
17722
+ }
17723
+ if (stateDb) {
17724
+ tracker.start("edge_weights", {});
17725
+ try {
17726
+ const edgeWeightAgeMs = lastEdgeWeightRebuildAt > 0 ? Date.now() - lastEdgeWeightRebuildAt : Infinity;
17727
+ if (edgeWeightAgeMs >= 60 * 60 * 1e3) {
17728
+ const result = recomputeEdgeWeights(stateDb);
17729
+ lastEdgeWeightRebuildAt = Date.now();
17730
+ tracker.end({ rebuilt: true, edges: result.edges_updated, duration_ms: result.duration_ms });
17731
+ serverLog("watcher", `Edge weights: ${result.edges_updated} edges in ${result.duration_ms}ms`);
17732
+ } else {
17733
+ tracker.end({ rebuilt: false, age_ms: edgeWeightAgeMs });
17734
+ serverLog("watcher", `Edge weights: cache valid (${Math.round(edgeWeightAgeMs / 1e3)}s old)`);
17735
+ }
17736
+ } catch (e) {
17737
+ tracker.end({ error: String(e) });
17738
+ serverLog("watcher", `Edge weights: failed: ${e}`);
17739
+ }
17740
+ }
17426
17741
  if (hasEmbeddingsIndex()) {
17427
17742
  tracker.start("note_embeddings", { files: filteredEvents.length });
17428
17743
  let embUpdated = 0;
@@ -17561,6 +17876,7 @@ async function runPostIndexWork(index) {
17561
17876
  linkDiffs.push({ file: entry.file, ...diff });
17562
17877
  }
17563
17878
  updateStoredNoteLinks(stateDb, entry.file, currentSet);
17879
+ if (diff.removed.length === 0) continue;
17564
17880
  for (const link of currentSet) {
17565
17881
  if (!previousSet.has(link)) continue;
17566
17882
  upsertHistory.run(entry.file, link);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.0.39",
3
+ "version": "2.0.41",
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",
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@modelcontextprotocol/sdk": "^1.25.1",
55
- "@velvetmonkey/vault-core": "^2.0.39",
55
+ "@velvetmonkey/vault-core": "^2.0.40",
56
56
  "better-sqlite3": "^11.0.0",
57
57
  "chokidar": "^4.0.0",
58
58
  "gray-matter": "^4.0.3",