@velvetmonkey/flywheel-memory 2.0.40 → 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 +286 -10
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -3721,13 +3721,25 @@ function getStoredNoteLinks(stateDb2, notePath) {
3721
3721
  return new Set(rows.map((r) => r.target));
3722
3722
  }
3723
3723
  function updateStoredNoteLinks(stateDb2, notePath, currentLinks) {
3724
- const del = stateDb2.db.prepare("DELETE FROM note_links WHERE note_path = ?");
3725
- 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
+ );
3726
3733
  const tx = stateDb2.db.transaction(() => {
3727
- del.run(notePath);
3728
3734
  for (const target of currentLinks) {
3729
3735
  ins.run(notePath, target);
3730
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
+ }
3731
3743
  });
3732
3744
  tx();
3733
3745
  }
@@ -7315,7 +7327,7 @@ var GetBacklinksOutputSchema = {
7315
7327
  returned_count: z.coerce.number().describe("Number of backlinks returned (may be limited)"),
7316
7328
  backlinks: z.array(BacklinkItemSchema).describe("List of backlinks")
7317
7329
  };
7318
- function registerGraphTools(server2, getIndex, getVaultPath) {
7330
+ function registerGraphTools(server2, getIndex, getVaultPath, getStateDb) {
7319
7331
  server2.registerTool(
7320
7332
  "get_backlinks",
7321
7333
  {
@@ -7437,6 +7449,89 @@ function registerGraphTools(server2, getIndex, getVaultPath) {
7437
7449
  };
7438
7450
  }
7439
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
+ );
7440
7535
  }
7441
7536
 
7442
7537
  // src/tools/read/wikilinks.ts
@@ -8827,9 +8922,11 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
8827
8922
  // Entity options (used with scope "entities")
8828
8923
  prefix: z4.boolean().default(false).describe("Enable prefix matching for entity search (autocomplete)"),
8829
8924
  // Pagination
8830
- 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.")
8831
8928
  },
8832
- 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 }) => {
8833
8930
  const limit = Math.min(requestedLimit ?? 20, MAX_LIMIT);
8834
8931
  const index = getIndex();
8835
8932
  const vaultPath2 = getVaultPath();
@@ -8940,17 +9037,47 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
8940
9037
  }
8941
9038
  }
8942
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
+ }
8943
9067
  if (hasEmbeddingsIndex()) {
8944
9068
  try {
8945
9069
  const semanticResults = await semanticSearch(query, limit);
8946
9070
  const fts5Ranked = fts5Results.map((r) => ({ path: r.path, title: r.title, snippet: r.snippet }));
8947
9071
  const semanticRanked = semanticResults.map((r) => ({ path: r.path, title: r.title }));
8948
- const entityRanked = entityResults.map((r) => ({ path: r.path, title: r.name }));
8949
- 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);
8950
9076
  const allPaths = /* @__PURE__ */ new Set([
8951
9077
  ...fts5Results.map((r) => r.path),
8952
9078
  ...semanticResults.map((r) => r.path),
8953
- ...entityResults.map((r) => r.path)
9079
+ ...entityResults.map((r) => r.path),
9080
+ ...edgeRanked.map((r) => r.path)
8954
9081
  ]);
8955
9082
  const fts5Map = new Map(fts5Results.map((r) => [r.path, r]));
8956
9083
  const semanticMap = new Map(semanticResults.map((r) => [r.path, r]));
@@ -9075,6 +9202,123 @@ function suggestEntityAliases(stateDb2, folder) {
9075
9202
  return suggestions;
9076
9203
  }
9077
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
+
9078
9322
  // src/tools/read/system.ts
9079
9323
  function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfig, getStateDb) {
9080
9324
  const RefreshIndexOutputSchema = {
@@ -9082,6 +9326,7 @@ function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfi
9082
9326
  notes_count: z5.number().describe("Number of notes indexed"),
9083
9327
  entities_count: z5.number().describe("Number of entities (titles + aliases)"),
9084
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"),
9085
9330
  duration_ms: z5.number().describe("Time taken to rebuild index")
9086
9331
  };
9087
9332
  server2.registerTool(
@@ -9149,6 +9394,16 @@ function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfi
9149
9394
  } catch (err) {
9150
9395
  console.error("[Flywheel] FTS5 rebuild failed:", err);
9151
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
+ }
9152
9407
  const duration = Date.now() - startTime;
9153
9408
  if (stateDb2) {
9154
9409
  recordIndexEvent(stateDb2, {
@@ -9162,6 +9417,7 @@ function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfi
9162
9417
  notes_count: newIndex.notes.size,
9163
9418
  entities_count: newIndex.entities.size,
9164
9419
  fts5_notes: fts5Notes,
9420
+ edges_recomputed: edgesRecomputed,
9165
9421
  duration_ms: duration
9166
9422
  };
9167
9423
  return {
@@ -16953,7 +17209,7 @@ registerSystemTools(
16953
17209
  },
16954
17210
  () => stateDb
16955
17211
  );
16956
- registerGraphTools(server, () => vaultIndex, () => vaultPath);
17212
+ registerGraphTools(server, () => vaultIndex, () => vaultPath, () => stateDb);
16957
17213
  registerWikilinkTools(server, () => vaultIndex, () => vaultPath);
16958
17214
  registerQueryTools(server, () => vaultIndex, () => vaultPath, () => stateDb);
16959
17215
  registerPrimitiveTools(server, () => vaultIndex, () => vaultPath, () => flywheelConfig);
@@ -17007,6 +17263,7 @@ async function main() {
17007
17263
  loadEntityEmbeddingsToMemory();
17008
17264
  setWriteStateDb(stateDb);
17009
17265
  setRecencyStateDb(stateDb);
17266
+ setEdgeWeightStateDb(stateDb);
17010
17267
  } catch (err) {
17011
17268
  const msg = err instanceof Error ? err.message : String(err);
17012
17269
  serverLog("statedb", `StateDb initialization failed: ${msg}`, "error");
@@ -17104,6 +17361,7 @@ async function main() {
17104
17361
  }
17105
17362
  var DEFAULT_ENTITY_EXCLUDE_FOLDERS = ["node_modules", "templates", "attachments", "tmp"];
17106
17363
  var lastCooccurrenceRebuildAt = 0;
17364
+ var lastEdgeWeightRebuildAt = 0;
17107
17365
  async function updateEntitiesInStateDb() {
17108
17366
  if (!stateDb) return;
17109
17367
  try {
@@ -17462,6 +17720,24 @@ async function runPostIndexWork(index) {
17462
17720
  tracker.end({ error: String(e) });
17463
17721
  serverLog("watcher", `Co-occurrence: failed: ${e}`);
17464
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
+ }
17465
17741
  if (hasEmbeddingsIndex()) {
17466
17742
  tracker.start("note_embeddings", { files: filteredEvents.length });
17467
17743
  let embUpdated = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.0.40",
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",