@velvetmonkey/flywheel-memory 2.0.49 → 2.0.50

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 +148 -9
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -7381,7 +7381,7 @@ function refreshIfStale(vaultPath2, index, excludeTags) {
7381
7381
  }
7382
7382
 
7383
7383
  // src/index.ts
7384
- import { openStateDb, scanVaultEntities as scanVaultEntities3, getSessionId, getAllEntitiesFromDb as getAllEntitiesFromDb3, findEntityMatches as findEntityMatches2, getProtectedZones as getProtectedZones2, rangeOverlapsProtectedZone } from "@velvetmonkey/vault-core";
7384
+ import { openStateDb, scanVaultEntities as scanVaultEntities3, getSessionId, getAllEntitiesFromDb as getAllEntitiesFromDb3, findEntityMatches as findEntityMatches2, getProtectedZones as getProtectedZones2, rangeOverlapsProtectedZone, detectImplicitEntities as detectImplicitEntities3 } from "@velvetmonkey/vault-core";
7385
7385
 
7386
7386
  // src/tools/read/graph.ts
7387
7387
  import * as fs9 from "fs";
@@ -7909,6 +7909,7 @@ function registerGraphTools(server2, getIndex, getVaultPath, getStateDb) {
7909
7909
 
7910
7910
  // src/tools/read/wikilinks.ts
7911
7911
  import { z as z2 } from "zod";
7912
+ import { detectImplicitEntities as detectImplicitEntities2 } from "@velvetmonkey/vault-core";
7912
7913
  function findEntityMatches(text, entities) {
7913
7914
  const matches = [];
7914
7915
  const sortedEntities = Array.from(entities.entries()).filter(([name]) => name.length >= 2).sort((a, b) => b[0].length - a[0].length);
@@ -8014,28 +8015,83 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
8014
8015
  text: z2.string().describe("The text to analyze for potential wikilinks"),
8015
8016
  limit: z2.coerce.number().default(50).describe("Maximum number of suggestions to return"),
8016
8017
  offset: z2.coerce.number().default(0).describe("Number of suggestions to skip (for pagination)"),
8017
- detail: z2.boolean().default(false).describe("Include per-layer score breakdown for each suggestion"),
8018
- note_path: z2.string().optional().describe("Path of the note being analyzed (enables strictness override for daily notes)")
8018
+ detail: z2.boolean().default(false).describe("Include per-layer score breakdown for each suggestion")
8019
8019
  },
8020
8020
  outputSchema: SuggestWikilinksOutputSchema
8021
8021
  },
8022
- async ({ text, limit: requestedLimit, offset, detail, note_path }) => {
8022
+ async ({ text, limit: requestedLimit, offset, detail }) => {
8023
8023
  const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
8024
8024
  const index = getIndex();
8025
8025
  const allMatches = findEntityMatches(text, index.entities);
8026
8026
  const matches = allMatches.slice(offset, offset + limit);
8027
+ const linkedSet = new Set(allMatches.map((m) => m.entity.toLowerCase()));
8028
+ const prospects = [];
8029
+ const prospectSeen = /* @__PURE__ */ new Set();
8030
+ for (const [target, links] of index.backlinks) {
8031
+ if (links.length < 2) continue;
8032
+ if (index.entities.has(target.toLowerCase())) continue;
8033
+ if (linkedSet.has(target.toLowerCase())) continue;
8034
+ const targetLower = target.toLowerCase();
8035
+ const textLower = text.toLowerCase();
8036
+ let searchPos = 0;
8037
+ while (searchPos < textLower.length) {
8038
+ const pos = textLower.indexOf(targetLower, searchPos);
8039
+ if (pos === -1) break;
8040
+ const end = pos + target.length;
8041
+ const before = pos > 0 ? text[pos - 1] : " ";
8042
+ const after = end < text.length ? text[end] : " ";
8043
+ if (/[\s\n\r.,;:!?()[\]{}'"<>-]/.test(before) && /[\s\n\r.,;:!?()[\]{}'"<>-]/.test(after)) {
8044
+ if (!prospectSeen.has(targetLower)) {
8045
+ prospectSeen.add(targetLower);
8046
+ prospects.push({
8047
+ entity: text.substring(pos, end),
8048
+ start: pos,
8049
+ end,
8050
+ source: "dead_link",
8051
+ confidence: links.length >= 3 ? "high" : "medium",
8052
+ backlink_count: links.length
8053
+ });
8054
+ }
8055
+ break;
8056
+ }
8057
+ searchPos = pos + 1;
8058
+ }
8059
+ }
8060
+ const implicit = detectImplicitEntities2(text);
8061
+ for (const imp of implicit) {
8062
+ const impLower = imp.text.toLowerCase();
8063
+ if (linkedSet.has(impLower)) continue;
8064
+ if (prospectSeen.has(impLower)) {
8065
+ const existing = prospects.find((p) => p.entity.toLowerCase() === impLower);
8066
+ if (existing) {
8067
+ existing.source = "both";
8068
+ existing.confidence = "high";
8069
+ }
8070
+ continue;
8071
+ }
8072
+ prospectSeen.add(impLower);
8073
+ prospects.push({
8074
+ entity: imp.text,
8075
+ start: imp.start,
8076
+ end: imp.end,
8077
+ source: "implicit",
8078
+ confidence: "low"
8079
+ });
8080
+ }
8027
8081
  const output = {
8028
8082
  input_length: text.length,
8029
8083
  suggestion_count: allMatches.length,
8030
8084
  returned_count: matches.length,
8031
8085
  suggestions: matches
8032
8086
  };
8087
+ if (prospects.length > 0) {
8088
+ output.prospects = prospects;
8089
+ }
8033
8090
  if (detail) {
8034
8091
  const scored = await suggestRelatedLinks(text, {
8035
8092
  detail: true,
8036
8093
  maxSuggestions: limit,
8037
- strictness: "balanced",
8038
- ...note_path ? { notePath: note_path } : {}
8094
+ strictness: "balanced"
8039
8095
  });
8040
8096
  if (scored.detailed) {
8041
8097
  output.scored_suggestions = scored.detailed;
@@ -18625,14 +18681,22 @@ async function runPostIndexWork(index) {
18625
18681
  }
18626
18682
  }
18627
18683
  }
18684
+ const newDeadLinks = [];
18685
+ for (const diff of linkDiffs) {
18686
+ const newDead = diff.added.filter((target) => !vaultIndex.entities.has(target.toLowerCase()));
18687
+ if (newDead.length > 0) {
18688
+ newDeadLinks.push({ file: diff.file, targets: newDead });
18689
+ }
18690
+ }
18628
18691
  tracker.end({
18629
18692
  total_resolved: totalResolved,
18630
18693
  total_dead: totalDead,
18631
18694
  links: forwardLinkResults,
18632
18695
  link_diffs: linkDiffs,
18633
- survived: survivedLinks
18696
+ survived: survivedLinks,
18697
+ new_dead_links: newDeadLinks
18634
18698
  });
18635
- serverLog("watcher", `Forward links: ${totalResolved} resolved, ${totalDead} dead`);
18699
+ serverLog("watcher", `Forward links: ${totalResolved} resolved, ${totalDead} dead${newDeadLinks.length > 0 ? `, ${newDeadLinks.reduce((s, d) => s + d.targets.length, 0)} new dead` : ""}`);
18636
18700
  tracker.start("wikilink_check", { files: filteredEvents.length });
18637
18701
  const trackedLinks = [];
18638
18702
  if (stateDb) {
@@ -18696,6 +18760,7 @@ async function runPostIndexWork(index) {
18696
18760
  tracker.end({ tracked: trackedLinks, mentions: mentionResults });
18697
18761
  serverLog("watcher", `Wikilink check: ${trackedLinks.reduce((s, t) => s + t.entities.length, 0)} tracked links in ${trackedLinks.length} files, ${mentionResults.reduce((s, m) => s + m.entities.length, 0)} unwikified mentions`);
18698
18762
  tracker.start("implicit_feedback", { files: filteredEvents.length });
18763
+ const preSuppressed = stateDb ? new Set(getAllSuppressionPenalties(stateDb).keys()) : /* @__PURE__ */ new Set();
18699
18764
  const feedbackResults = [];
18700
18765
  if (stateDb) {
18701
18766
  for (const event of filteredEvents) {
@@ -18740,10 +18805,84 @@ async function runPostIndexWork(index) {
18740
18805
  }
18741
18806
  }
18742
18807
  }
18743
- tracker.end({ removals: feedbackResults, additions: additionResults });
18808
+ const newlySuppressed = [];
18809
+ if (stateDb) {
18810
+ const postSuppressed = getAllSuppressionPenalties(stateDb);
18811
+ for (const entity of postSuppressed.keys()) {
18812
+ if (!preSuppressed.has(entity)) {
18813
+ newlySuppressed.push(entity);
18814
+ }
18815
+ }
18816
+ }
18817
+ tracker.end({ removals: feedbackResults, additions: additionResults, newly_suppressed: newlySuppressed });
18744
18818
  if (feedbackResults.length > 0 || additionResults.length > 0) {
18745
18819
  serverLog("watcher", `Implicit feedback: ${feedbackResults.length} removals, ${additionResults.length} manual additions detected`);
18746
18820
  }
18821
+ if (newlySuppressed.length > 0) {
18822
+ serverLog("watcher", `Suppression: ${newlySuppressed.length} entities newly suppressed: ${newlySuppressed.join(", ")}`);
18823
+ }
18824
+ tracker.start("prospect_scan", { files: filteredEvents.length });
18825
+ const prospectResults = [];
18826
+ for (const event of filteredEvents) {
18827
+ if (event.type === "delete" || !event.path.endsWith(".md")) continue;
18828
+ try {
18829
+ const content = await fs31.readFile(path32.join(vaultPath, event.path), "utf-8");
18830
+ const zones = getProtectedZones2(content);
18831
+ const linkedSet = new Set(
18832
+ (forwardLinkResults.find((r) => r.file === event.path)?.resolved ?? []).concat(forwardLinkResults.find((r) => r.file === event.path)?.dead ?? []).map((n) => n.toLowerCase())
18833
+ );
18834
+ const knownEntitySet = new Set(entitiesAfter.map((e) => e.nameLower));
18835
+ const implicitMatches = detectImplicitEntities3(content);
18836
+ const implicitNames = implicitMatches.filter((imp) => !linkedSet.has(imp.text.toLowerCase()) && !knownEntitySet.has(imp.text.toLowerCase())).map((imp) => imp.text);
18837
+ const deadLinkMatches = [];
18838
+ for (const [key, links] of vaultIndex.backlinks) {
18839
+ if (links.length < 2 || vaultIndex.entities.has(key) || linkedSet.has(key)) continue;
18840
+ const matches = findEntityMatches2(content, key, true);
18841
+ if (matches.some((m) => !rangeOverlapsProtectedZone(m.start, m.end, zones))) {
18842
+ deadLinkMatches.push(key);
18843
+ }
18844
+ }
18845
+ if (implicitNames.length > 0 || deadLinkMatches.length > 0) {
18846
+ prospectResults.push({ file: event.path, implicit: implicitNames, deadLinkMatches });
18847
+ }
18848
+ } catch {
18849
+ }
18850
+ }
18851
+ tracker.end({ prospects: prospectResults });
18852
+ if (prospectResults.length > 0) {
18853
+ const implicitCount = prospectResults.reduce((s, p) => s + p.implicit.length, 0);
18854
+ const deadCount = prospectResults.reduce((s, p) => s + p.deadLinkMatches.length, 0);
18855
+ serverLog("watcher", `Prospect scan: ${implicitCount} implicit entities, ${deadCount} dead link matches across ${prospectResults.length} files`);
18856
+ }
18857
+ tracker.start("suggestion_scoring", { files: filteredEvents.length });
18858
+ const suggestionResults = [];
18859
+ for (const event of filteredEvents) {
18860
+ if (event.type === "delete" || !event.path.endsWith(".md")) continue;
18861
+ try {
18862
+ const content = await fs31.readFile(path32.join(vaultPath, event.path), "utf-8");
18863
+ const result = await suggestRelatedLinks(content, {
18864
+ maxSuggestions: 5,
18865
+ strictness: "balanced",
18866
+ notePath: event.path,
18867
+ detail: true
18868
+ });
18869
+ if (result.detailed && result.detailed.length > 0) {
18870
+ suggestionResults.push({
18871
+ file: event.path,
18872
+ top: result.detailed.slice(0, 5).map((s) => ({
18873
+ entity: s.entity,
18874
+ score: s.totalScore,
18875
+ confidence: s.confidence
18876
+ }))
18877
+ });
18878
+ }
18879
+ } catch {
18880
+ }
18881
+ }
18882
+ tracker.end({ scored_files: suggestionResults.length, suggestions: suggestionResults });
18883
+ if (suggestionResults.length > 0) {
18884
+ serverLog("watcher", `Suggestion scoring: ${suggestionResults.length} files scored`);
18885
+ }
18747
18886
  tracker.start("tag_scan", { files: filteredEvents.length });
18748
18887
  const tagDiffs = [];
18749
18888
  if (stateDb) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.0.49",
3
+ "version": "2.0.50",
4
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",
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@modelcontextprotocol/sdk": "^1.25.1",
55
- "@velvetmonkey/vault-core": "^2.0.49",
55
+ "@velvetmonkey/vault-core": "^2.0.50",
56
56
  "better-sqlite3": "^11.0.0",
57
57
  "chokidar": "^4.0.0",
58
58
  "gray-matter": "^4.0.3",