@velvetmonkey/flywheel-crank 0.5.1 → 0.6.0

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 +579 -121
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -492,10 +492,498 @@ import {
492
492
  loadEntityCache,
493
493
  saveEntityCache
494
494
  } from "@velvetmonkey/vault-core";
495
+ import path4 from "path";
496
+
497
+ // src/core/stemmer.ts
498
+ var STOPWORDS = /* @__PURE__ */ new Set([
499
+ "the",
500
+ "a",
501
+ "an",
502
+ "and",
503
+ "or",
504
+ "but",
505
+ "in",
506
+ "on",
507
+ "at",
508
+ "to",
509
+ "for",
510
+ "of",
511
+ "with",
512
+ "by",
513
+ "from",
514
+ "as",
515
+ "is",
516
+ "was",
517
+ "are",
518
+ "were",
519
+ "been",
520
+ "be",
521
+ "have",
522
+ "has",
523
+ "had",
524
+ "do",
525
+ "does",
526
+ "did",
527
+ "will",
528
+ "would",
529
+ "could",
530
+ "should",
531
+ "may",
532
+ "might",
533
+ "must",
534
+ "shall",
535
+ "can",
536
+ "need",
537
+ "this",
538
+ "that",
539
+ "these",
540
+ "those",
541
+ "i",
542
+ "you",
543
+ "he",
544
+ "she",
545
+ "it",
546
+ "we",
547
+ "they",
548
+ "what",
549
+ "which",
550
+ "who",
551
+ "whom",
552
+ "when",
553
+ "where",
554
+ "why",
555
+ "how",
556
+ "all",
557
+ "each",
558
+ "every",
559
+ "both",
560
+ "few",
561
+ "more",
562
+ "most",
563
+ "other",
564
+ "some",
565
+ "such",
566
+ "no",
567
+ "not",
568
+ "only",
569
+ "own",
570
+ "same",
571
+ "so",
572
+ "than",
573
+ "too",
574
+ "very",
575
+ "just",
576
+ "also",
577
+ "about",
578
+ "after",
579
+ "before",
580
+ "being",
581
+ "between",
582
+ "into",
583
+ "through",
584
+ "during",
585
+ "above",
586
+ "below",
587
+ "out",
588
+ "off",
589
+ "over",
590
+ "under",
591
+ "again",
592
+ "further",
593
+ "then",
594
+ "once",
595
+ "here",
596
+ "there",
597
+ "any",
598
+ "now",
599
+ "new",
600
+ "even",
601
+ "much",
602
+ "back",
603
+ "going"
604
+ ]);
605
+ function isConsonant(word, i) {
606
+ const c = word[i];
607
+ if (c === "a" || c === "e" || c === "i" || c === "o" || c === "u") {
608
+ return false;
609
+ }
610
+ if (c === "y") {
611
+ return i === 0 || !isConsonant(word, i - 1);
612
+ }
613
+ return true;
614
+ }
615
+ function measure(word, end) {
616
+ let n = 0;
617
+ let i = 0;
618
+ while (i <= end) {
619
+ if (!isConsonant(word, i))
620
+ break;
621
+ i++;
622
+ }
623
+ if (i > end)
624
+ return n;
625
+ i++;
626
+ while (true) {
627
+ while (i <= end) {
628
+ if (isConsonant(word, i))
629
+ break;
630
+ i++;
631
+ }
632
+ if (i > end)
633
+ return n;
634
+ n++;
635
+ i++;
636
+ while (i <= end) {
637
+ if (!isConsonant(word, i))
638
+ break;
639
+ i++;
640
+ }
641
+ if (i > end)
642
+ return n;
643
+ i++;
644
+ }
645
+ }
646
+ function hasVowel(word, end) {
647
+ for (let i = 0; i <= end; i++) {
648
+ if (!isConsonant(word, i))
649
+ return true;
650
+ }
651
+ return false;
652
+ }
653
+ function endsWithDoubleConsonant(word, end) {
654
+ if (end < 1)
655
+ return false;
656
+ if (word[end] !== word[end - 1])
657
+ return false;
658
+ return isConsonant(word, end);
659
+ }
660
+ function cvcPattern(word, i) {
661
+ if (i < 2)
662
+ return false;
663
+ if (!isConsonant(word, i) || isConsonant(word, i - 1) || !isConsonant(word, i - 2)) {
664
+ return false;
665
+ }
666
+ const c = word[i];
667
+ return c !== "w" && c !== "x" && c !== "y";
668
+ }
669
+ function replaceSuffix(word, suffix, replacement, minMeasure) {
670
+ if (!word.endsWith(suffix))
671
+ return word;
672
+ const stem2 = word.slice(0, word.length - suffix.length);
673
+ if (measure(stem2, stem2.length - 1) > minMeasure) {
674
+ return stem2 + replacement;
675
+ }
676
+ return word;
677
+ }
678
+ function step1a(word) {
679
+ if (word.endsWith("sses")) {
680
+ return word.slice(0, -2);
681
+ }
682
+ if (word.endsWith("ies")) {
683
+ return word.slice(0, -2);
684
+ }
685
+ if (word.endsWith("ss")) {
686
+ return word;
687
+ }
688
+ if (word.endsWith("s")) {
689
+ return word.slice(0, -1);
690
+ }
691
+ return word;
692
+ }
693
+ function step1b(word) {
694
+ if (word.endsWith("eed")) {
695
+ const stem3 = word.slice(0, -3);
696
+ if (measure(stem3, stem3.length - 1) > 0) {
697
+ return stem3 + "ee";
698
+ }
699
+ return word;
700
+ }
701
+ let stem2 = "";
702
+ let didRemove = false;
703
+ if (word.endsWith("ed")) {
704
+ stem2 = word.slice(0, -2);
705
+ if (hasVowel(stem2, stem2.length - 1)) {
706
+ word = stem2;
707
+ didRemove = true;
708
+ }
709
+ } else if (word.endsWith("ing")) {
710
+ stem2 = word.slice(0, -3);
711
+ if (hasVowel(stem2, stem2.length - 1)) {
712
+ word = stem2;
713
+ didRemove = true;
714
+ }
715
+ }
716
+ if (didRemove) {
717
+ if (word.endsWith("at") || word.endsWith("bl") || word.endsWith("iz")) {
718
+ return word + "e";
719
+ }
720
+ if (endsWithDoubleConsonant(word, word.length - 1)) {
721
+ const c = word[word.length - 1];
722
+ if (c !== "l" && c !== "s" && c !== "z") {
723
+ return word.slice(0, -1);
724
+ }
725
+ }
726
+ if (measure(word, word.length - 1) === 1 && cvcPattern(word, word.length - 1)) {
727
+ return word + "e";
728
+ }
729
+ }
730
+ return word;
731
+ }
732
+ function step1c(word) {
733
+ if (word.endsWith("y")) {
734
+ const stem2 = word.slice(0, -1);
735
+ if (hasVowel(stem2, stem2.length - 1)) {
736
+ return stem2 + "i";
737
+ }
738
+ }
739
+ return word;
740
+ }
741
+ function step2(word) {
742
+ const suffixes = [
743
+ ["ational", "ate"],
744
+ ["tional", "tion"],
745
+ ["enci", "ence"],
746
+ ["anci", "ance"],
747
+ ["izer", "ize"],
748
+ ["abli", "able"],
749
+ ["alli", "al"],
750
+ ["entli", "ent"],
751
+ ["eli", "e"],
752
+ ["ousli", "ous"],
753
+ ["ization", "ize"],
754
+ ["ation", "ate"],
755
+ ["ator", "ate"],
756
+ ["alism", "al"],
757
+ ["iveness", "ive"],
758
+ ["fulness", "ful"],
759
+ ["ousness", "ous"],
760
+ ["aliti", "al"],
761
+ ["iviti", "ive"],
762
+ ["biliti", "ble"]
763
+ ];
764
+ for (const [suffix, replacement] of suffixes) {
765
+ if (word.endsWith(suffix)) {
766
+ return replaceSuffix(word, suffix, replacement, 0);
767
+ }
768
+ }
769
+ return word;
770
+ }
771
+ function step3(word) {
772
+ const suffixes = [
773
+ ["icate", "ic"],
774
+ ["ative", ""],
775
+ ["alize", "al"],
776
+ ["iciti", "ic"],
777
+ ["ical", "ic"],
778
+ ["ful", ""],
779
+ ["ness", ""]
780
+ ];
781
+ for (const [suffix, replacement] of suffixes) {
782
+ if (word.endsWith(suffix)) {
783
+ return replaceSuffix(word, suffix, replacement, 0);
784
+ }
785
+ }
786
+ return word;
787
+ }
788
+ function step4(word) {
789
+ const suffixes = [
790
+ "al",
791
+ "ance",
792
+ "ence",
793
+ "er",
794
+ "ic",
795
+ "able",
796
+ "ible",
797
+ "ant",
798
+ "ement",
799
+ "ment",
800
+ "ent",
801
+ "ion",
802
+ "ou",
803
+ "ism",
804
+ "ate",
805
+ "iti",
806
+ "ous",
807
+ "ive",
808
+ "ize"
809
+ ];
810
+ for (const suffix of suffixes) {
811
+ if (word.endsWith(suffix)) {
812
+ const stem2 = word.slice(0, word.length - suffix.length);
813
+ if (measure(stem2, stem2.length - 1) > 1) {
814
+ if (suffix === "ion") {
815
+ const lastChar = stem2[stem2.length - 1];
816
+ if (lastChar === "s" || lastChar === "t") {
817
+ return stem2;
818
+ }
819
+ } else {
820
+ return stem2;
821
+ }
822
+ }
823
+ }
824
+ }
825
+ return word;
826
+ }
827
+ function step5a(word) {
828
+ if (word.endsWith("e")) {
829
+ const stem2 = word.slice(0, -1);
830
+ const m = measure(stem2, stem2.length - 1);
831
+ if (m > 1) {
832
+ return stem2;
833
+ }
834
+ if (m === 1 && !cvcPattern(stem2, stem2.length - 1)) {
835
+ return stem2;
836
+ }
837
+ }
838
+ return word;
839
+ }
840
+ function step5b(word) {
841
+ if (word.endsWith("ll")) {
842
+ const stem2 = word.slice(0, -1);
843
+ if (measure(stem2, stem2.length - 1) > 1) {
844
+ return stem2;
845
+ }
846
+ }
847
+ return word;
848
+ }
849
+ function stem(word) {
850
+ word = word.toLowerCase();
851
+ if (word.length < 3) {
852
+ return word;
853
+ }
854
+ word = step1a(word);
855
+ word = step1b(word);
856
+ word = step1c(word);
857
+ word = step2(word);
858
+ word = step3(word);
859
+ word = step4(word);
860
+ word = step5a(word);
861
+ word = step5b(word);
862
+ return word;
863
+ }
864
+ function tokenize(text) {
865
+ const cleanText = text.replace(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g, "$1").replace(/[*_`#\[\]()]/g, " ").toLowerCase();
866
+ const words = cleanText.match(/\b[a-z]{4,}\b/g) || [];
867
+ return words.filter((word) => !STOPWORDS.has(word));
868
+ }
869
+
870
+ // src/core/cooccurrence.ts
871
+ import { readdir, readFile } from "fs/promises";
495
872
  import path3 from "path";
873
+ var DEFAULT_MIN_COOCCURRENCE = 2;
874
+ var EXCLUDED_FOLDERS = /* @__PURE__ */ new Set([
875
+ "templates",
876
+ ".obsidian",
877
+ ".claude",
878
+ ".git"
879
+ ]);
880
+ function noteContainsEntity(content, entityName) {
881
+ const entityTokens = tokenize(entityName);
882
+ if (entityTokens.length === 0)
883
+ return false;
884
+ const contentTokens = new Set(tokenize(content));
885
+ let matchCount = 0;
886
+ for (const token of entityTokens) {
887
+ if (contentTokens.has(token)) {
888
+ matchCount++;
889
+ }
890
+ }
891
+ if (entityTokens.length === 1) {
892
+ return matchCount === 1;
893
+ }
894
+ return matchCount / entityTokens.length >= 0.5;
895
+ }
896
+ function incrementCooccurrence(associations, entityA, entityB) {
897
+ if (!associations[entityA]) {
898
+ associations[entityA] = /* @__PURE__ */ new Map();
899
+ }
900
+ const current = associations[entityA].get(entityB) || 0;
901
+ associations[entityA].set(entityB, current + 1);
902
+ }
903
+ async function* walkMarkdownFiles(dir, baseDir) {
904
+ try {
905
+ const entries = await readdir(dir, { withFileTypes: true });
906
+ for (const entry of entries) {
907
+ const fullPath = path3.join(dir, entry.name);
908
+ const relativePath = path3.relative(baseDir, fullPath);
909
+ const topFolder = relativePath.split(path3.sep)[0];
910
+ if (EXCLUDED_FOLDERS.has(topFolder)) {
911
+ continue;
912
+ }
913
+ if (entry.isDirectory()) {
914
+ yield* walkMarkdownFiles(fullPath, baseDir);
915
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
916
+ yield { path: fullPath, relativePath };
917
+ }
918
+ }
919
+ } catch {
920
+ }
921
+ }
922
+ async function mineCooccurrences(vaultPath2, entities, options = {}) {
923
+ const { minCount = DEFAULT_MIN_COOCCURRENCE } = options;
924
+ const associations = {};
925
+ let notesScanned = 0;
926
+ const validEntities = entities.filter((e) => e.length <= 30);
927
+ for await (const file of walkMarkdownFiles(vaultPath2, vaultPath2)) {
928
+ try {
929
+ const content = await readFile(file.path, "utf-8");
930
+ notesScanned++;
931
+ const mentionedEntities = [];
932
+ for (const entity of validEntities) {
933
+ if (noteContainsEntity(content, entity)) {
934
+ mentionedEntities.push(entity);
935
+ }
936
+ }
937
+ for (const entityA of mentionedEntities) {
938
+ for (const entityB of mentionedEntities) {
939
+ if (entityA !== entityB) {
940
+ incrementCooccurrence(associations, entityA, entityB);
941
+ }
942
+ }
943
+ }
944
+ } catch {
945
+ }
946
+ }
947
+ let totalAssociations = 0;
948
+ for (const entityAssocs of Object.values(associations)) {
949
+ for (const count of entityAssocs.values()) {
950
+ if (count >= minCount) {
951
+ totalAssociations++;
952
+ }
953
+ }
954
+ }
955
+ return {
956
+ associations,
957
+ minCount,
958
+ _metadata: {
959
+ generated_at: (/* @__PURE__ */ new Date()).toISOString(),
960
+ total_associations: totalAssociations,
961
+ notes_scanned: notesScanned
962
+ }
963
+ };
964
+ }
965
+ function getCooccurrenceBoost(entityName, matchedEntities, cooccurrenceIndex2) {
966
+ if (!cooccurrenceIndex2)
967
+ return 0;
968
+ let boost = 0;
969
+ const { associations, minCount } = cooccurrenceIndex2;
970
+ for (const matched of matchedEntities) {
971
+ const entityAssocs = associations[matched];
972
+ if (entityAssocs) {
973
+ const count = entityAssocs.get(entityName) || 0;
974
+ if (count >= minCount) {
975
+ boost += 3;
976
+ }
977
+ }
978
+ }
979
+ return boost;
980
+ }
981
+
982
+ // src/core/wikilinks.ts
496
983
  var entityIndex = null;
497
984
  var indexReady = false;
498
985
  var indexError = null;
986
+ var cooccurrenceIndex = null;
499
987
  var DEFAULT_EXCLUDE_FOLDERS = [
500
988
  "daily-notes",
501
989
  "daily",
@@ -508,7 +996,7 @@ var DEFAULT_EXCLUDE_FOLDERS = [
508
996
  "templates"
509
997
  ];
510
998
  async function initializeEntityIndex(vaultPath2) {
511
- const cacheFile = path3.join(vaultPath2, ".claude", "wikilink-entities.json");
999
+ const cacheFile = path4.join(vaultPath2, ".claude", "wikilink-entities.json");
512
1000
  try {
513
1001
  const cached = await loadEntityCache(cacheFile);
514
1002
  if (cached) {
@@ -534,8 +1022,17 @@ async function rebuildIndex(vaultPath2, cacheFile) {
534
1022
  excludeFolders: DEFAULT_EXCLUDE_FOLDERS
535
1023
  });
536
1024
  indexReady = true;
537
- const duration = Date.now() - startTime;
538
- console.error(`[Crank] Entity index built: ${entityIndex._metadata.total_entities} entities in ${duration}ms`);
1025
+ const entityDuration = Date.now() - startTime;
1026
+ console.error(`[Crank] Entity index built: ${entityIndex._metadata.total_entities} entities in ${entityDuration}ms`);
1027
+ try {
1028
+ const cooccurrenceStart = Date.now();
1029
+ const entities = getAllEntities(entityIndex);
1030
+ cooccurrenceIndex = await mineCooccurrences(vaultPath2, entities);
1031
+ const cooccurrenceDuration = Date.now() - cooccurrenceStart;
1032
+ console.error(`[Crank] Co-occurrence index built: ${cooccurrenceIndex._metadata.total_associations} associations in ${cooccurrenceDuration}ms`);
1033
+ } catch (e) {
1034
+ console.error(`[Crank] Failed to build co-occurrence index: ${e}`);
1035
+ }
539
1036
  try {
540
1037
  await saveEntityCache(cacheFile, entityIndex);
541
1038
  console.error(`[Crank] Entity cache saved`);
@@ -579,86 +1076,6 @@ function maybeApplyWikilinks(content, skipWikilinks) {
579
1076
  return { content: result.content };
580
1077
  }
581
1078
  var SUGGESTION_PATTERN = /→\s*\[\[.+$/;
582
- var STOPWORDS = /* @__PURE__ */ new Set([
583
- "the",
584
- "a",
585
- "an",
586
- "and",
587
- "or",
588
- "but",
589
- "in",
590
- "on",
591
- "at",
592
- "to",
593
- "for",
594
- "of",
595
- "with",
596
- "by",
597
- "from",
598
- "as",
599
- "is",
600
- "was",
601
- "are",
602
- "were",
603
- "been",
604
- "be",
605
- "have",
606
- "has",
607
- "had",
608
- "do",
609
- "does",
610
- "did",
611
- "will",
612
- "would",
613
- "could",
614
- "should",
615
- "may",
616
- "might",
617
- "must",
618
- "shall",
619
- "can",
620
- "need",
621
- "this",
622
- "that",
623
- "these",
624
- "those",
625
- "i",
626
- "you",
627
- "he",
628
- "she",
629
- "it",
630
- "we",
631
- "they",
632
- "what",
633
- "which",
634
- "who",
635
- "whom",
636
- "when",
637
- "where",
638
- "why",
639
- "how",
640
- "all",
641
- "each",
642
- "every",
643
- "both",
644
- "few",
645
- "more",
646
- "most",
647
- "other",
648
- "some",
649
- "such",
650
- "no",
651
- "not",
652
- "only",
653
- "own",
654
- "same",
655
- "so",
656
- "than",
657
- "too",
658
- "very",
659
- "just",
660
- "also"
661
- ]);
662
1079
  function extractLinkedEntities(content) {
663
1080
  const linked = /* @__PURE__ */ new Set();
664
1081
  const wikilinkRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
@@ -668,23 +1085,37 @@ function extractLinkedEntities(content) {
668
1085
  }
669
1086
  return linked;
670
1087
  }
671
- function tokenizeContent(content) {
672
- const cleanContent = content.replace(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g, "$1").replace(/[*_`#\[\]()]/g, " ").toLowerCase();
673
- const words = cleanContent.match(/\b[a-z]{4,}\b/g) || [];
674
- return words.filter((word) => !STOPWORDS.has(word));
1088
+ function tokenizeForMatching(content) {
1089
+ const tokens = tokenize(content);
1090
+ const tokenSet = new Set(tokens);
1091
+ const stems = new Set(tokens.map((t) => stem(t)));
1092
+ return { tokens: tokenSet, stems };
675
1093
  }
676
- function scoreEntity(entityName, contentTokens) {
677
- const entityWords = entityName.toLowerCase().split(/\s+/);
1094
+ var MAX_ENTITY_LENGTH = 30;
1095
+ var MIN_SUGGESTION_SCORE = 5;
1096
+ var MIN_MATCH_RATIO = 0.4;
1097
+ function scoreEntity(entityName, contentTokens, contentStems) {
1098
+ const entityTokens = tokenize(entityName);
1099
+ if (entityTokens.length === 0)
1100
+ return 0;
1101
+ const entityStems = entityTokens.map((t) => stem(t));
678
1102
  let score = 0;
679
- for (const entityWord of entityWords) {
680
- if (entityWord.length < 3)
681
- continue;
682
- for (const contentToken of contentTokens) {
683
- if (contentToken === entityWord) {
684
- score += 3;
685
- } else if (contentToken.includes(entityWord) || entityWord.includes(contentToken)) {
686
- score += 1;
687
- }
1103
+ let matchedWords = 0;
1104
+ for (let i = 0; i < entityTokens.length; i++) {
1105
+ const token = entityTokens[i];
1106
+ const entityStem = entityStems[i];
1107
+ if (contentTokens.has(token)) {
1108
+ score += 10;
1109
+ matchedWords++;
1110
+ } else if (contentStems.has(entityStem)) {
1111
+ score += 5;
1112
+ matchedWords++;
1113
+ }
1114
+ }
1115
+ if (entityTokens.length > 1) {
1116
+ const matchRatio = matchedWords / entityTokens.length;
1117
+ if (matchRatio < MIN_MATCH_RATIO) {
1118
+ return 0;
688
1119
  }
689
1120
  }
690
1121
  return score;
@@ -702,24 +1133,51 @@ function suggestRelatedLinks(content, options = {}) {
702
1133
  if (entities.length === 0) {
703
1134
  return emptyResult;
704
1135
  }
705
- const contentTokens = tokenizeContent(content);
706
- if (contentTokens.length === 0) {
1136
+ const { tokens: contentTokens, stems: contentStems } = tokenizeForMatching(content);
1137
+ if (contentTokens.size === 0) {
707
1138
  return emptyResult;
708
1139
  }
709
1140
  const linkedEntities = excludeLinked ? extractLinkedEntities(content) : /* @__PURE__ */ new Set();
710
1141
  const scoredEntities = [];
1142
+ const directlyMatchedEntities = /* @__PURE__ */ new Set();
711
1143
  for (const entity of entities) {
712
1144
  const entityName = typeof entity === "string" ? entity : entity.name;
713
1145
  if (!entityName)
714
1146
  continue;
1147
+ if (entityName.length > MAX_ENTITY_LENGTH) {
1148
+ continue;
1149
+ }
715
1150
  if (linkedEntities.has(entityName.toLowerCase())) {
716
1151
  continue;
717
1152
  }
718
- const score = scoreEntity(entityName, contentTokens);
1153
+ const score = scoreEntity(entityName, contentTokens, contentStems);
719
1154
  if (score > 0) {
1155
+ directlyMatchedEntities.add(entityName);
1156
+ }
1157
+ if (score >= MIN_SUGGESTION_SCORE) {
720
1158
  scoredEntities.push({ name: entityName, score });
721
1159
  }
722
1160
  }
1161
+ if (cooccurrenceIndex && directlyMatchedEntities.size > 0) {
1162
+ for (const entity of entities) {
1163
+ const entityName = typeof entity === "string" ? entity : entity.name;
1164
+ if (!entityName)
1165
+ continue;
1166
+ if (entityName.length > MAX_ENTITY_LENGTH)
1167
+ continue;
1168
+ if (linkedEntities.has(entityName.toLowerCase()))
1169
+ continue;
1170
+ const boost = getCooccurrenceBoost(entityName, directlyMatchedEntities, cooccurrenceIndex);
1171
+ if (boost > 0) {
1172
+ const existing = scoredEntities.find((e) => e.name === entityName);
1173
+ if (existing) {
1174
+ existing.score += boost;
1175
+ } else if (boost >= MIN_SUGGESTION_SCORE) {
1176
+ scoredEntities.push({ name: entityName, score: boost });
1177
+ }
1178
+ }
1179
+ }
1180
+ }
723
1181
  scoredEntities.sort((a, b) => b.score - a.score);
724
1182
  const topSuggestions = scoredEntities.slice(0, maxSuggestions).map((e) => e.name);
725
1183
  if (topSuggestions.length === 0) {
@@ -734,7 +1192,7 @@ function suggestRelatedLinks(content, options = {}) {
734
1192
 
735
1193
  // src/tools/mutations.ts
736
1194
  import fs2 from "fs/promises";
737
- import path4 from "path";
1195
+ import path5 from "path";
738
1196
  function registerMutationTools(server2, vaultPath2) {
739
1197
  server2.tool(
740
1198
  "vault_add_to_section",
@@ -752,7 +1210,7 @@ function registerMutationTools(server2, vaultPath2) {
752
1210
  },
753
1211
  async ({ path: notePath, section, content, position, format, commit, skipWikilinks, preserveListNesting, suggestOutgoingLinks }) => {
754
1212
  try {
755
- const fullPath = path4.join(vaultPath2, notePath);
1213
+ const fullPath = path5.join(vaultPath2, notePath);
756
1214
  try {
757
1215
  await fs2.access(fullPath);
758
1216
  } catch {
@@ -836,7 +1294,7 @@ function registerMutationTools(server2, vaultPath2) {
836
1294
  },
837
1295
  async ({ path: notePath, section, pattern, mode, useRegex, commit }) => {
838
1296
  try {
839
- const fullPath = path4.join(vaultPath2, notePath);
1297
+ const fullPath = path5.join(vaultPath2, notePath);
840
1298
  try {
841
1299
  await fs2.access(fullPath);
842
1300
  } catch {
@@ -918,7 +1376,7 @@ function registerMutationTools(server2, vaultPath2) {
918
1376
  },
919
1377
  async ({ path: notePath, section, search, replacement, mode, useRegex, commit, skipWikilinks, suggestOutgoingLinks }) => {
920
1378
  try {
921
- const fullPath = path4.join(vaultPath2, notePath);
1379
+ const fullPath = path5.join(vaultPath2, notePath);
922
1380
  try {
923
1381
  await fs2.access(fullPath);
924
1382
  } catch {
@@ -1004,7 +1462,7 @@ function registerMutationTools(server2, vaultPath2) {
1004
1462
  // src/tools/tasks.ts
1005
1463
  import { z as z2 } from "zod";
1006
1464
  import fs3 from "fs/promises";
1007
- import path5 from "path";
1465
+ import path6 from "path";
1008
1466
  var TASK_REGEX = /^(\s*)-\s*\[([ xX])\]\s*(.*)$/;
1009
1467
  function findTasks(content, section) {
1010
1468
  const lines = content.split("\n");
@@ -1060,7 +1518,7 @@ function registerTaskTools(server2, vaultPath2) {
1060
1518
  },
1061
1519
  async ({ path: notePath, task, section, commit }) => {
1062
1520
  try {
1063
- const fullPath = path5.join(vaultPath2, notePath);
1521
+ const fullPath = path6.join(vaultPath2, notePath);
1064
1522
  try {
1065
1523
  await fs3.access(fullPath);
1066
1524
  } catch {
@@ -1155,7 +1613,7 @@ function registerTaskTools(server2, vaultPath2) {
1155
1613
  },
1156
1614
  async ({ path: notePath, section, task, position, completed, commit, skipWikilinks, suggestOutgoingLinks, preserveListNesting }) => {
1157
1615
  try {
1158
- const fullPath = path5.join(vaultPath2, notePath);
1616
+ const fullPath = path6.join(vaultPath2, notePath);
1159
1617
  try {
1160
1618
  await fs3.access(fullPath);
1161
1619
  } catch {
@@ -1232,7 +1690,7 @@ function registerTaskTools(server2, vaultPath2) {
1232
1690
  // src/tools/frontmatter.ts
1233
1691
  import { z as z3 } from "zod";
1234
1692
  import fs4 from "fs/promises";
1235
- import path6 from "path";
1693
+ import path7 from "path";
1236
1694
  function registerFrontmatterTools(server2, vaultPath2) {
1237
1695
  server2.tool(
1238
1696
  "vault_update_frontmatter",
@@ -1244,7 +1702,7 @@ function registerFrontmatterTools(server2, vaultPath2) {
1244
1702
  },
1245
1703
  async ({ path: notePath, frontmatter: updates, commit }) => {
1246
1704
  try {
1247
- const fullPath = path6.join(vaultPath2, notePath);
1705
+ const fullPath = path7.join(vaultPath2, notePath);
1248
1706
  try {
1249
1707
  await fs4.access(fullPath);
1250
1708
  } catch {
@@ -1300,7 +1758,7 @@ function registerFrontmatterTools(server2, vaultPath2) {
1300
1758
  },
1301
1759
  async ({ path: notePath, key, value, commit }) => {
1302
1760
  try {
1303
- const fullPath = path6.join(vaultPath2, notePath);
1761
+ const fullPath = path7.join(vaultPath2, notePath);
1304
1762
  try {
1305
1763
  await fs4.access(fullPath);
1306
1764
  } catch {
@@ -1357,7 +1815,7 @@ function registerFrontmatterTools(server2, vaultPath2) {
1357
1815
  // src/tools/notes.ts
1358
1816
  import { z as z4 } from "zod";
1359
1817
  import fs5 from "fs/promises";
1360
- import path7 from "path";
1818
+ import path8 from "path";
1361
1819
  function registerNoteTools(server2, vaultPath2) {
1362
1820
  server2.tool(
1363
1821
  "vault_create_note",
@@ -1379,7 +1837,7 @@ function registerNoteTools(server2, vaultPath2) {
1379
1837
  };
1380
1838
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1381
1839
  }
1382
- const fullPath = path7.join(vaultPath2, notePath);
1840
+ const fullPath = path8.join(vaultPath2, notePath);
1383
1841
  try {
1384
1842
  await fs5.access(fullPath);
1385
1843
  if (!overwrite) {
@@ -1392,7 +1850,7 @@ function registerNoteTools(server2, vaultPath2) {
1392
1850
  }
1393
1851
  } catch {
1394
1852
  }
1395
- const dir = path7.dirname(fullPath);
1853
+ const dir = path8.dirname(fullPath);
1396
1854
  await fs5.mkdir(dir, { recursive: true });
1397
1855
  await writeVaultFile(vaultPath2, notePath, content, frontmatter);
1398
1856
  let gitCommit;
@@ -1451,7 +1909,7 @@ Content length: ${content.length} chars`,
1451
1909
  };
1452
1910
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1453
1911
  }
1454
- const fullPath = path7.join(vaultPath2, notePath);
1912
+ const fullPath = path8.join(vaultPath2, notePath);
1455
1913
  try {
1456
1914
  await fs5.access(fullPath);
1457
1915
  } catch {
@@ -1497,7 +1955,7 @@ Content length: ${content.length} chars`,
1497
1955
  // src/tools/system.ts
1498
1956
  import { z as z5 } from "zod";
1499
1957
  import fs6 from "fs/promises";
1500
- import path8 from "path";
1958
+ import path9 from "path";
1501
1959
  function registerSystemTools(server2, vaultPath2) {
1502
1960
  server2.tool(
1503
1961
  "vault_list_sections",
@@ -1517,7 +1975,7 @@ function registerSystemTools(server2, vaultPath2) {
1517
1975
  };
1518
1976
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1519
1977
  }
1520
- const fullPath = path8.join(vaultPath2, notePath);
1978
+ const fullPath = path9.join(vaultPath2, notePath);
1521
1979
  try {
1522
1980
  await fs6.access(fullPath);
1523
1981
  } catch {
@@ -1625,18 +2083,18 @@ Message: ${undoResult.undoneCommit.message}` : void 0
1625
2083
 
1626
2084
  // src/core/vaultRoot.ts
1627
2085
  import * as fs7 from "fs";
1628
- import * as path9 from "path";
2086
+ import * as path10 from "path";
1629
2087
  var VAULT_MARKERS = [".obsidian", ".claude"];
1630
2088
  function findVaultRoot(startPath) {
1631
- let current = path9.resolve(startPath || process.cwd());
2089
+ let current = path10.resolve(startPath || process.cwd());
1632
2090
  while (true) {
1633
2091
  for (const marker of VAULT_MARKERS) {
1634
- const markerPath = path9.join(current, marker);
2092
+ const markerPath = path10.join(current, marker);
1635
2093
  if (fs7.existsSync(markerPath) && fs7.statSync(markerPath).isDirectory()) {
1636
2094
  return current;
1637
2095
  }
1638
2096
  }
1639
- const parent = path9.dirname(current);
2097
+ const parent = path10.dirname(current);
1640
2098
  if (parent === current) {
1641
2099
  return startPath || process.cwd();
1642
2100
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-crank",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Deterministic vault mutations for Obsidian via MCP",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",