@velvetmonkey/flywheel-memory 2.0.122 → 2.0.123

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 +213 -82
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -10720,7 +10720,7 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig2 = () =>
10720
10720
  import { z as z4 } from "zod";
10721
10721
  init_embeddings();
10722
10722
  import {
10723
- searchEntities,
10723
+ searchEntities as searchEntities2,
10724
10724
  searchEntitiesPrefix
10725
10725
  } from "@velvetmonkey/vault-core";
10726
10726
 
@@ -10729,7 +10729,6 @@ import {
10729
10729
  getEntityByName as getEntityByName2
10730
10730
  } from "@velvetmonkey/vault-core";
10731
10731
  var TOP_LINKS = 10;
10732
- var RECALL_TOP_LINKS = 5;
10733
10732
  function recencyDecay(modifiedDate) {
10734
10733
  if (!modifiedDate) return 0.5;
10735
10734
  const daysSince = (Date.now() - modifiedDate.getTime()) / (1e3 * 60 * 60 * 24);
@@ -10819,6 +10818,63 @@ function rankBacklinks(backlinks, notePath, index, stateDb2, maxLinks = TOP_LINK
10819
10818
  return out;
10820
10819
  }).sort((a, b) => (b.weight ?? 1) - (a.weight ?? 1)).slice(0, maxLinks);
10821
10820
  }
10821
+ var COMPACT_OUTLINK_NAMES = 10;
10822
+ function enrichResultCompact(result, index, stateDb2, opts) {
10823
+ const note = index.notes.get(result.path);
10824
+ const normalizedPath = result.path.toLowerCase().replace(/\.md$/, "");
10825
+ const backlinks = index.backlinks.get(normalizedPath) || [];
10826
+ const enriched = {
10827
+ path: result.path,
10828
+ title: result.title
10829
+ };
10830
+ if (result.snippet) {
10831
+ enriched.snippet = result.snippet;
10832
+ } else {
10833
+ const preview = getContentPreview(result.path);
10834
+ if (preview) enriched.snippet = preview;
10835
+ }
10836
+ if (note) {
10837
+ enriched.backlink_count = backlinks.length;
10838
+ enriched.modified = note.modified.toISOString();
10839
+ if (note.tags.length > 0) enriched.tags = note.tags;
10840
+ if (note.outlinks.length > 0) {
10841
+ enriched.outlink_names = getOutlinkNames(note.outlinks, result.path, index, stateDb2, COMPACT_OUTLINK_NAMES);
10842
+ }
10843
+ }
10844
+ if (stateDb2) {
10845
+ try {
10846
+ const entity = getEntityByName2(stateDb2, result.title);
10847
+ if (entity) {
10848
+ enriched.category = entity.category;
10849
+ enriched.hub_score = entity.hubScore;
10850
+ if (!enriched.snippet && entity.description) {
10851
+ enriched.snippet = entity.description;
10852
+ }
10853
+ }
10854
+ } catch {
10855
+ }
10856
+ }
10857
+ if (opts?.via) enriched.via = opts.via;
10858
+ if (opts?.hop) enriched.hop = opts.hop;
10859
+ return enriched;
10860
+ }
10861
+ function getOutlinkNames(outlinks, notePath, index, stateDb2, max) {
10862
+ const weightMap = /* @__PURE__ */ new Map();
10863
+ if (stateDb2) {
10864
+ try {
10865
+ const rows = stateDb2.db.prepare(
10866
+ "SELECT target, weight, weight_updated_at FROM note_links WHERE note_path = ?"
10867
+ ).all(notePath);
10868
+ for (const row of rows) {
10869
+ const daysSince = row.weight_updated_at ? (Date.now() - row.weight_updated_at) / (1e3 * 60 * 60 * 24) : 0;
10870
+ const decay = Math.max(0.1, 1 - daysSince / 180);
10871
+ weightMap.set(row.target, row.weight * decay);
10872
+ }
10873
+ } catch {
10874
+ }
10875
+ }
10876
+ return outlinks.map((l) => ({ name: l.target, weight: weightMap.get(l.target.toLowerCase()) ?? 1 })).sort((a, b) => b.weight - a.weight).slice(0, max).map((l) => l.name);
10877
+ }
10822
10878
  function enrichResult(result, index, stateDb2) {
10823
10879
  const note = index.notes.get(result.path);
10824
10880
  const normalizedPath = result.path.toLowerCase().replace(/\.md$/, "");
@@ -10886,7 +10942,7 @@ function enrichResultLight(result, index, stateDb2) {
10886
10942
  }
10887
10943
  return enriched;
10888
10944
  }
10889
- function enrichEntityResult(entityName, stateDb2, index) {
10945
+ function enrichEntityCompact(entityName, stateDb2, index) {
10890
10946
  const enriched = {};
10891
10947
  if (stateDb2) {
10892
10948
  try {
@@ -10908,36 +10964,27 @@ function enrichEntityResult(entityName, stateDb2, index) {
10908
10964
  const backlinks = index.backlinks.get(normalizedPath) || [];
10909
10965
  enriched.backlink_count = backlinks.length;
10910
10966
  if (note) {
10911
- enriched.outlink_count = note.outlinks.length;
10912
10967
  if (note.tags.length > 0) enriched.tags = note.tags;
10913
- if (backlinks.length > 0) {
10914
- enriched.top_backlinks = rankBacklinks(backlinks, entityPath, index, stateDb2, RECALL_TOP_LINKS);
10915
- }
10916
10968
  if (note.outlinks.length > 0) {
10917
- enriched.top_outlinks = rankOutlinks(note.outlinks, entityPath, index, stateDb2, RECALL_TOP_LINKS);
10969
+ enriched.outlink_names = getOutlinkNames(note.outlinks, entityPath, index, stateDb2, COMPACT_OUTLINK_NAMES);
10918
10970
  }
10919
10971
  }
10920
10972
  }
10921
10973
  }
10922
10974
  return enriched;
10923
10975
  }
10924
- function enrichNoteResult(notePath, stateDb2, index) {
10976
+ function enrichNoteCompact(notePath, stateDb2, index) {
10925
10977
  const enriched = {};
10926
10978
  if (!index) return enriched;
10927
10979
  const note = index.notes.get(notePath);
10928
10980
  if (!note) return enriched;
10929
10981
  const normalizedPath = notePath.toLowerCase().replace(/\.md$/, "");
10930
10982
  const backlinks = index.backlinks.get(normalizedPath) || [];
10931
- enriched.frontmatter = note.frontmatter;
10932
10983
  if (note.tags.length > 0) enriched.tags = note.tags;
10933
10984
  enriched.backlink_count = backlinks.length;
10934
- enriched.outlink_count = note.outlinks.length;
10935
10985
  enriched.modified = note.modified.toISOString();
10936
- if (backlinks.length > 0) {
10937
- enriched.top_backlinks = rankBacklinks(backlinks, notePath, index, stateDb2, RECALL_TOP_LINKS);
10938
- }
10939
10986
  if (note.outlinks.length > 0) {
10940
- enriched.top_outlinks = rankOutlinks(note.outlinks, notePath, index, stateDb2, RECALL_TOP_LINKS);
10987
+ enriched.outlink_names = getOutlinkNames(note.outlinks, notePath, index, stateDb2, COMPACT_OUTLINK_NAMES);
10941
10988
  }
10942
10989
  if (stateDb2) {
10943
10990
  try {
@@ -10952,8 +10999,132 @@ function enrichNoteResult(notePath, stateDb2, index) {
10952
10999
  return enriched;
10953
11000
  }
10954
11001
 
11002
+ // src/core/read/multihop.ts
11003
+ import { getEntityByName as getEntityByName3, searchEntities } from "@velvetmonkey/vault-core";
11004
+ var DEFAULT_CONFIG2 = {
11005
+ maxParents: 10,
11006
+ maxHops: 2,
11007
+ maxOutlinksPerHop: 10,
11008
+ maxBackfill: 10
11009
+ };
11010
+ function multiHopBackfill(primaryResults, index, stateDb2, config = {}) {
11011
+ const cfg = { ...DEFAULT_CONFIG2, ...config };
11012
+ const seen = new Set(primaryResults.map((r) => r.path).filter(Boolean));
11013
+ const candidates = [];
11014
+ const hop1Results = [];
11015
+ for (const primary of primaryResults.slice(0, cfg.maxParents)) {
11016
+ const primaryPath = primary.path;
11017
+ if (!primaryPath) continue;
11018
+ const note = index.notes.get(primaryPath);
11019
+ if (!note) continue;
11020
+ for (const outlink of note.outlinks.slice(0, cfg.maxOutlinksPerHop)) {
11021
+ const targetPath = index.entities.get(outlink.target.toLowerCase());
11022
+ if (!targetPath || seen.has(targetPath)) continue;
11023
+ seen.add(targetPath);
11024
+ const targetNote = index.notes.get(targetPath);
11025
+ const title = targetNote?.title ?? outlink.target;
11026
+ hop1Results.push({ path: targetPath, title, via: primaryPath });
11027
+ }
11028
+ }
11029
+ for (const h1 of hop1Results) {
11030
+ const enriched = enrichResultCompact(
11031
+ { path: h1.path, title: h1.title },
11032
+ index,
11033
+ stateDb2,
11034
+ { via: h1.via, hop: 1 }
11035
+ );
11036
+ const score = scoreCandidate(h1.path, index, stateDb2);
11037
+ candidates.push({ result: enriched, score });
11038
+ }
11039
+ if (cfg.maxHops >= 2) {
11040
+ for (const h1 of hop1Results) {
11041
+ const note = index.notes.get(h1.path);
11042
+ if (!note) continue;
11043
+ for (const outlink of note.outlinks.slice(0, cfg.maxOutlinksPerHop)) {
11044
+ const targetPath = index.entities.get(outlink.target.toLowerCase());
11045
+ if (!targetPath || seen.has(targetPath)) continue;
11046
+ seen.add(targetPath);
11047
+ const targetNote = index.notes.get(targetPath);
11048
+ const title = targetNote?.title ?? outlink.target;
11049
+ const enriched = enrichResultCompact(
11050
+ { path: targetPath, title },
11051
+ index,
11052
+ stateDb2,
11053
+ { via: h1.path, hop: 2 }
11054
+ );
11055
+ const score = scoreCandidate(targetPath, index, stateDb2);
11056
+ candidates.push({ result: enriched, score });
11057
+ }
11058
+ }
11059
+ }
11060
+ candidates.sort((a, b) => b.score - a.score);
11061
+ return candidates.slice(0, cfg.maxBackfill).map((c) => c.result);
11062
+ }
11063
+ function scoreCandidate(path33, index, stateDb2) {
11064
+ const note = index.notes.get(path33);
11065
+ const decay = recencyDecay(note?.modified);
11066
+ let hubScore = 1;
11067
+ if (stateDb2) {
11068
+ try {
11069
+ const title = note?.title ?? path33.replace(/\.md$/, "").split("/").pop() ?? "";
11070
+ const entity = getEntityByName3(stateDb2, title);
11071
+ if (entity) hubScore = entity.hubScore ?? 1;
11072
+ } catch {
11073
+ }
11074
+ }
11075
+ return hubScore * decay;
11076
+ }
11077
+ function extractExpansionTerms(results, originalQuery, index) {
11078
+ const queryLower = originalQuery.toLowerCase();
11079
+ const terms = /* @__PURE__ */ new Set();
11080
+ for (const r of results.slice(0, 5)) {
11081
+ const outlinks = r.outlink_names;
11082
+ if (outlinks) {
11083
+ for (const name of outlinks) {
11084
+ if (!queryLower.includes(name.toLowerCase()) && index.entities.has(name.toLowerCase())) {
11085
+ terms.add(name);
11086
+ }
11087
+ }
11088
+ }
11089
+ const snippet = r.snippet;
11090
+ if (snippet) {
11091
+ const matches = snippet.match(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g);
11092
+ if (matches) {
11093
+ for (const wl of matches) {
11094
+ const name = wl.replace(/\[\[|\]\]/g, "").split("|")[0];
11095
+ if (!queryLower.includes(name.toLowerCase()) && index.entities.has(name.toLowerCase())) {
11096
+ terms.add(name);
11097
+ }
11098
+ }
11099
+ }
11100
+ }
11101
+ }
11102
+ return Array.from(terms).slice(0, 10);
11103
+ }
11104
+ function expandQuery(expansionTerms, primaryResults, index, stateDb2) {
11105
+ if (!stateDb2 || expansionTerms.length === 0) return [];
11106
+ const seen = new Set(primaryResults.map((r) => r.path).filter(Boolean));
11107
+ const results = [];
11108
+ for (const term of expansionTerms) {
11109
+ try {
11110
+ const entities = searchEntities(stateDb2, term, 3);
11111
+ for (const entity of entities) {
11112
+ if (!entity.path || seen.has(entity.path)) continue;
11113
+ seen.add(entity.path);
11114
+ results.push(enrichResultCompact(
11115
+ { path: entity.path, title: entity.name },
11116
+ index,
11117
+ stateDb2,
11118
+ { via: "query_expansion" }
11119
+ ));
11120
+ }
11121
+ } catch {
11122
+ }
11123
+ }
11124
+ return results;
11125
+ }
11126
+
10955
11127
  // src/tools/read/query.ts
10956
- init_wikilinkFeedback();
10957
11128
  function matchesFrontmatter(note, where) {
10958
11129
  for (const [key, value] of Object.entries(where)) {
10959
11130
  const noteValue = note.frontmatter[key];
@@ -11134,7 +11305,7 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb2) {
11134
11305
  const stateDbEntity = getStateDb2();
11135
11306
  if (stateDbEntity) {
11136
11307
  try {
11137
- entityResults = searchEntities(stateDbEntity, query, limit);
11308
+ entityResults = searchEntities2(stateDbEntity, query, limit);
11138
11309
  } catch {
11139
11310
  }
11140
11311
  }
@@ -11206,46 +11377,22 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb2) {
11206
11377
  scored.sort((a, b) => b.rrf_score - a.rrf_score);
11207
11378
  const filtered = applyFolderFilter(scored);
11208
11379
  const stateDb2 = getStateDb2();
11209
- const results = filtered.slice(0, limit).map((item, i) => ({
11210
- ...(i < detailN ? enrichResult : enrichResultLight)({ path: item.path, title: item.title, snippet: item.snippet }, index, stateDb2),
11380
+ const results2 = filtered.slice(0, limit).map((item) => ({
11381
+ ...enrichResultCompact({ path: item.path, title: item.title, snippet: item.snippet }, index, stateDb2),
11211
11382
  rrf_score: item.rrf_score,
11212
11383
  in_fts5: item.in_fts5,
11213
11384
  in_semantic: item.in_semantic,
11214
11385
  in_entity: item.in_entity
11215
11386
  }));
11216
- if (stateDb2 && results.length < limit) {
11217
- const existingPaths = new Set(results.map((r) => r.path));
11218
- const backfill = [];
11219
- for (const r of results.slice(0, 3)) {
11220
- const rPath = r.path;
11221
- if (!rPath) continue;
11222
- try {
11223
- const outlinks = getStoredNoteLinks(stateDb2, rPath);
11224
- for (const target of outlinks) {
11225
- const entityRow = stateDb2.db.prepare(
11226
- "SELECT path FROM entities WHERE name_lower = ?"
11227
- ).get(target);
11228
- if (entityRow?.path && !existingPaths.has(entityRow.path)) {
11229
- existingPaths.add(entityRow.path);
11230
- backfill.push({
11231
- ...enrichResultLight({ path: entityRow.path, title: target }, index, stateDb2),
11232
- rrf_score: 0,
11233
- in_fts5: false,
11234
- in_semantic: false,
11235
- in_entity: false
11236
- });
11237
- }
11238
- }
11239
- } catch {
11240
- }
11241
- }
11242
- results.push(...backfill.slice(0, limit - results.length));
11243
- }
11387
+ const hopResults2 = multiHopBackfill(results2, index, stateDb2, { maxBackfill: limit });
11388
+ const expansionTerms2 = extractExpansionTerms(results2, query, index);
11389
+ const expansionResults2 = expandQuery(expansionTerms2, [...results2, ...hopResults2], index, stateDb2);
11390
+ results2.push(...hopResults2, ...expansionResults2);
11244
11391
  return { content: [{ type: "text", text: JSON.stringify({
11245
11392
  method: "hybrid",
11246
11393
  query,
11247
11394
  total_results: filtered.length,
11248
- results
11395
+ results: results2
11249
11396
  }, null, 2) }] };
11250
11397
  } catch (err) {
11251
11398
  console.error("[Semantic] Hybrid search failed, falling back to FTS5:", err instanceof Error ? err.message : err);
@@ -11262,49 +11409,33 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb2) {
11262
11409
  const filtered = applyFolderFilter(mergedItems);
11263
11410
  const stateDb2 = getStateDb2();
11264
11411
  const sliced = filtered.slice(0, limit);
11265
- const results = sliced.map((item, i) => ({
11266
- ...(i < detailN ? enrichResult : enrichResultLight)({ path: item.path, title: item.title, snippet: item.snippet }, index, stateDb2),
11412
+ const results2 = sliced.map((item) => ({
11413
+ ...enrichResultCompact({ path: item.path, title: item.title, snippet: item.snippet }, index, stateDb2),
11267
11414
  ..."in_fts5" in item ? { in_fts5: true } : { in_entity: true }
11268
11415
  }));
11269
- if (stateDb2 && results.length < limit) {
11270
- const existingPaths = new Set(results.map((r) => r.path));
11271
- const backfill = [];
11272
- for (const r of results.slice(0, 3)) {
11273
- const rPath = r.path;
11274
- if (!rPath) continue;
11275
- try {
11276
- const outlinks = getStoredNoteLinks(stateDb2, rPath);
11277
- for (const target of outlinks) {
11278
- const entityRow = stateDb2.db.prepare(
11279
- "SELECT path FROM entities WHERE name_lower = ?"
11280
- ).get(target);
11281
- if (entityRow?.path && !existingPaths.has(entityRow.path)) {
11282
- existingPaths.add(entityRow.path);
11283
- backfill.push({
11284
- ...enrichResultLight({ path: entityRow.path, title: target }, index, stateDb2)
11285
- });
11286
- }
11287
- }
11288
- } catch {
11289
- }
11290
- }
11291
- results.push(...backfill.slice(0, limit - results.length));
11292
- }
11416
+ const hopResults2 = multiHopBackfill(results2, index, stateDb2, { maxBackfill: limit });
11417
+ const expansionTerms2 = extractExpansionTerms(results2, query, index);
11418
+ const expansionResults2 = expandQuery(expansionTerms2, [...results2, ...hopResults2], index, stateDb2);
11419
+ results2.push(...hopResults2, ...expansionResults2);
11293
11420
  return { content: [{ type: "text", text: JSON.stringify({
11294
11421
  method: "fts5",
11295
11422
  query,
11296
11423
  total_results: filtered.length,
11297
- results
11424
+ results: results2
11298
11425
  }, null, 2) }] };
11299
11426
  }
11300
11427
  const stateDbFts = getStateDb2();
11301
11428
  const fts5Filtered = applyFolderFilter(fts5Results);
11302
- const enrichedFts5 = fts5Filtered.map((r, i) => ({ ...(i < detailN ? enrichResult : enrichResultLight)({ path: r.path, title: r.title, snippet: r.snippet }, index, stateDbFts), in_fts5: true }));
11429
+ const results = fts5Filtered.map((r) => ({ ...enrichResultCompact({ path: r.path, title: r.title, snippet: r.snippet }, index, stateDbFts), in_fts5: true }));
11430
+ const hopResults = multiHopBackfill(results, index, stateDbFts, { maxBackfill: limit });
11431
+ const expansionTerms = extractExpansionTerms(results, query, index);
11432
+ const expansionResults = expandQuery(expansionTerms, [...results, ...hopResults], index, stateDbFts);
11433
+ results.push(...hopResults, ...expansionResults);
11303
11434
  return { content: [{ type: "text", text: JSON.stringify({
11304
11435
  method: "fts5",
11305
11436
  query,
11306
- total_results: enrichedFts5.length,
11307
- results: enrichedFts5
11437
+ total_results: results.length,
11438
+ results
11308
11439
  }, null, 2) }] };
11309
11440
  }
11310
11441
  return { content: [{ type: "text", text: JSON.stringify({ error: "Provide a query or metadata filters (where, has_tag, folder, etc.)" }, null, 2) }] };
@@ -12030,7 +12161,7 @@ async function findSections(index, headingPattern, vaultPath2, folder) {
12030
12161
  }
12031
12162
 
12032
12163
  // src/tools/read/primitives.ts
12033
- import { getEntityByName as getEntityByName3 } from "@velvetmonkey/vault-core";
12164
+ import { getEntityByName as getEntityByName4 } from "@velvetmonkey/vault-core";
12034
12165
  function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig2 = () => ({}), getStateDb2 = () => null) {
12035
12166
  server2.registerTool(
12036
12167
  "get_note_structure",
@@ -12073,7 +12204,7 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig2 = ()
12073
12204
  const stateDb2 = getStateDb2();
12074
12205
  if (stateDb2 && note) {
12075
12206
  try {
12076
- const entity = getEntityByName3(stateDb2, note.title);
12207
+ const entity = getEntityByName4(stateDb2, note.title);
12077
12208
  if (entity) {
12078
12209
  enriched.category = entity.category;
12079
12210
  enriched.hub_score = entity.hubScore;
@@ -19325,13 +19456,13 @@ function registerRecallTools(server2, getStateDb2, getVaultPath, getIndex) {
19325
19456
  description: e.content,
19326
19457
  score: Math.round(e.score * 10) / 10,
19327
19458
  breakdown: e.breakdown,
19328
- ...enrichEntityResult(e.id, stateDb2, index)
19459
+ ...enrichEntityCompact(e.id, stateDb2, index)
19329
19460
  })),
19330
19461
  notes: notes.map((n) => ({
19331
19462
  path: n.id,
19332
19463
  snippet: n.content,
19333
19464
  score: Math.round(n.score * 10) / 10,
19334
- ...enrichNoteResult(n.id, stateDb2, index)
19465
+ ...enrichNoteCompact(n.id, stateDb2, index)
19335
19466
  })),
19336
19467
  memories: memories.map((m) => ({
19337
19468
  key: m.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.0.122",
3
+ "version": "2.0.123",
4
4
  "description": "MCP server that gives Claude full read/write access to your Obsidian vault. Select from 69 tools for search, backlinks, graph queries, mutations, agent memory, and hybrid semantic search.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",