@velvetmonkey/flywheel-crank 0.7.2 → 0.8.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 +474 -22
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -179,7 +179,29 @@ function insertInSection(content, section, newContent, position, options) {
179
179
  const lines = content.split("\n");
180
180
  const formattedContent = newContent.trim();
181
181
  if (position === "prepend") {
182
- lines.splice(section.contentStartLine, 0, formattedContent);
182
+ if (options?.preserveListNesting) {
183
+ let indent = "";
184
+ for (let i = section.contentStartLine; i <= section.endLine; i++) {
185
+ const line = lines[i];
186
+ const trimmed = line.trim();
187
+ if (trimmed === "")
188
+ continue;
189
+ const listMatch = line.match(/^(\s*)[-*+]\s|^(\s*)\d+\.\s|^(\s*)[-*+]\s*\[[ xX]\]/);
190
+ if (listMatch) {
191
+ indent = listMatch[1] || listMatch[2] || listMatch[3] || "";
192
+ break;
193
+ }
194
+ break;
195
+ }
196
+ if (indent) {
197
+ const indentedContent = formattedContent.split("\n").map((line) => indent + line).join("\n");
198
+ lines.splice(section.contentStartLine, 0, indentedContent);
199
+ } else {
200
+ lines.splice(section.contentStartLine, 0, formattedContent);
201
+ }
202
+ } else {
203
+ lines.splice(section.contentStartLine, 0, formattedContent);
204
+ }
183
205
  } else {
184
206
  let lastContentLineIdx = -1;
185
207
  for (let i = section.endLine; i >= section.contentStartLine; i--) {
@@ -496,6 +518,7 @@ import path4 from "path";
496
518
 
497
519
  // src/core/stemmer.ts
498
520
  var STOPWORDS = /* @__PURE__ */ new Set([
521
+ // Articles, pronouns, prepositions (basic)
499
522
  "the",
500
523
  "a",
501
524
  "an",
@@ -596,11 +619,385 @@ var STOPWORDS = /* @__PURE__ */ new Set([
596
619
  "there",
597
620
  "any",
598
621
  "now",
599
- "new",
600
622
  "even",
601
623
  "much",
602
624
  "back",
603
- "going"
625
+ // Common verbs (critical for reducing false positives)
626
+ // These create matches like "Completed" → "Complete Guide"
627
+ "going",
628
+ "went",
629
+ "gone",
630
+ "come",
631
+ "came",
632
+ "coming",
633
+ "work",
634
+ "worked",
635
+ "working",
636
+ "works",
637
+ "make",
638
+ "made",
639
+ "making",
640
+ "makes",
641
+ "take",
642
+ "took",
643
+ "taken",
644
+ "taking",
645
+ "takes",
646
+ "give",
647
+ "gave",
648
+ "given",
649
+ "giving",
650
+ "gives",
651
+ "find",
652
+ "found",
653
+ "finding",
654
+ "finds",
655
+ "know",
656
+ "knew",
657
+ "known",
658
+ "knowing",
659
+ "knows",
660
+ "think",
661
+ "thought",
662
+ "thinking",
663
+ "thinks",
664
+ "look",
665
+ "looked",
666
+ "looking",
667
+ "looks",
668
+ "want",
669
+ "wanted",
670
+ "wanting",
671
+ "wants",
672
+ "tell",
673
+ "told",
674
+ "telling",
675
+ "tells",
676
+ "keep",
677
+ "kept",
678
+ "keeping",
679
+ "keeps",
680
+ "start",
681
+ "started",
682
+ "starting",
683
+ "starts",
684
+ "complete",
685
+ "completed",
686
+ "completing",
687
+ "completes",
688
+ "finish",
689
+ "finished",
690
+ "finishing",
691
+ "finishes",
692
+ "begin",
693
+ "began",
694
+ "begun",
695
+ "beginning",
696
+ "begins",
697
+ "end",
698
+ "ended",
699
+ "ending",
700
+ "ends",
701
+ "add",
702
+ "added",
703
+ "adding",
704
+ "adds",
705
+ "update",
706
+ "updated",
707
+ "updating",
708
+ "updates",
709
+ "change",
710
+ "changed",
711
+ "changing",
712
+ "changes",
713
+ "remove",
714
+ "removed",
715
+ "removing",
716
+ "removes",
717
+ "fix",
718
+ "fixed",
719
+ "fixing",
720
+ "fixes",
721
+ "create",
722
+ "created",
723
+ "creating",
724
+ "creates",
725
+ "build",
726
+ "built",
727
+ "building",
728
+ "builds",
729
+ "run",
730
+ "ran",
731
+ "running",
732
+ "runs",
733
+ "test",
734
+ "tested",
735
+ "testing",
736
+ "tests",
737
+ "release",
738
+ "released",
739
+ "releasing",
740
+ "releases",
741
+ "use",
742
+ "used",
743
+ "using",
744
+ "uses",
745
+ "get",
746
+ "got",
747
+ "gotten",
748
+ "getting",
749
+ "gets",
750
+ "set",
751
+ "setting",
752
+ "sets",
753
+ "put",
754
+ "putting",
755
+ "puts",
756
+ "try",
757
+ "tried",
758
+ "trying",
759
+ "tries",
760
+ "move",
761
+ "moved",
762
+ "moving",
763
+ "moves",
764
+ "show",
765
+ "showed",
766
+ "shown",
767
+ "showing",
768
+ "shows",
769
+ "help",
770
+ "helped",
771
+ "helping",
772
+ "helps",
773
+ "read",
774
+ "reading",
775
+ "reads",
776
+ "write",
777
+ "wrote",
778
+ "written",
779
+ "writing",
780
+ "writes",
781
+ "call",
782
+ "called",
783
+ "calling",
784
+ "calls",
785
+ "feel",
786
+ "felt",
787
+ "feeling",
788
+ "feels",
789
+ "seem",
790
+ "seemed",
791
+ "seeming",
792
+ "seems",
793
+ "turn",
794
+ "turned",
795
+ "turning",
796
+ "turns",
797
+ "leave",
798
+ "left",
799
+ "leaving",
800
+ "leaves",
801
+ "play",
802
+ "played",
803
+ "playing",
804
+ "plays",
805
+ "hold",
806
+ "held",
807
+ "holding",
808
+ "holds",
809
+ "bring",
810
+ "brought",
811
+ "bringing",
812
+ "brings",
813
+ "happen",
814
+ "happened",
815
+ "happening",
816
+ "happens",
817
+ "include",
818
+ "included",
819
+ "including",
820
+ "includes",
821
+ "continue",
822
+ "continued",
823
+ "continuing",
824
+ "continues",
825
+ "send",
826
+ "sent",
827
+ "sending",
828
+ "sends",
829
+ "receive",
830
+ "received",
831
+ "receiving",
832
+ "receives",
833
+ "follow",
834
+ "followed",
835
+ "following",
836
+ "follows",
837
+ "stop",
838
+ "stopped",
839
+ "stopping",
840
+ "stops",
841
+ "open",
842
+ "opened",
843
+ "opening",
844
+ "opens",
845
+ "close",
846
+ "closed",
847
+ "closing",
848
+ "closes",
849
+ "done",
850
+ "doing",
851
+ // Time words
852
+ "today",
853
+ "tomorrow",
854
+ "yesterday",
855
+ "daily",
856
+ "weekly",
857
+ "monthly",
858
+ "yearly",
859
+ "annually",
860
+ "morning",
861
+ "afternoon",
862
+ "evening",
863
+ "night",
864
+ "week",
865
+ "month",
866
+ "year",
867
+ "hour",
868
+ "minute",
869
+ "second",
870
+ "time",
871
+ "date",
872
+ "day",
873
+ "days",
874
+ "weeks",
875
+ "months",
876
+ "years",
877
+ "currently",
878
+ "recently",
879
+ "later",
880
+ "earlier",
881
+ "soon",
882
+ "always",
883
+ "never",
884
+ "sometimes",
885
+ "often",
886
+ "usually",
887
+ "rarely",
888
+ // Generic/filler words
889
+ "thing",
890
+ "things",
891
+ "stuff",
892
+ "something",
893
+ "anything",
894
+ "nothing",
895
+ "everything",
896
+ "someone",
897
+ "anyone",
898
+ "noone",
899
+ "everyone",
900
+ "somewhere",
901
+ "anywhere",
902
+ "nowhere",
903
+ "everywhere",
904
+ "good",
905
+ "better",
906
+ "best",
907
+ "great",
908
+ "nice",
909
+ "okay",
910
+ "fine",
911
+ "right",
912
+ "wrong",
913
+ "bad",
914
+ "worse",
915
+ "worst",
916
+ "lot",
917
+ "lots",
918
+ "many",
919
+ "several",
920
+ "various",
921
+ "different",
922
+ "similar",
923
+ "another",
924
+ "next",
925
+ "last",
926
+ "first",
927
+ "second",
928
+ "third",
929
+ "new",
930
+ "old",
931
+ "big",
932
+ "small",
933
+ "large",
934
+ "little",
935
+ "long",
936
+ "short",
937
+ "high",
938
+ "low",
939
+ "full",
940
+ "empty",
941
+ "whole",
942
+ "part",
943
+ "real",
944
+ "true",
945
+ "false",
946
+ "actual",
947
+ "main",
948
+ "important",
949
+ // Descriptive/qualifier words
950
+ "really",
951
+ "actually",
952
+ "basically",
953
+ "probably",
954
+ "definitely",
955
+ "certainly",
956
+ "possibly",
957
+ "maybe",
958
+ "perhaps",
959
+ "like",
960
+ "likely",
961
+ "unlikely",
962
+ "almost",
963
+ "nearly",
964
+ "quite",
965
+ "rather",
966
+ "pretty",
967
+ "still",
968
+ "already",
969
+ "yet",
970
+ "though",
971
+ "although",
972
+ "however",
973
+ "therefore",
974
+ "thus",
975
+ "hence",
976
+ "truly",
977
+ "simply",
978
+ "easily",
979
+ "quickly",
980
+ "slowly",
981
+ "well",
982
+ "just",
983
+ "ever",
984
+ "either",
985
+ "neither",
986
+ "whether",
987
+ "because",
988
+ "since",
989
+ "while",
990
+ "until",
991
+ "unless",
992
+ "except",
993
+ "besides",
994
+ "anyway",
995
+ "otherwise",
996
+ "instead",
997
+ "meanwhile",
998
+ "furthermore",
999
+ "moreover",
1000
+ "nevertheless"
604
1001
  ]);
605
1002
  function isConsonant(word, i) {
606
1003
  const c = word[i];
@@ -1130,36 +1527,88 @@ function isLikelyArticleTitle(name) {
1130
1527
  }
1131
1528
  return false;
1132
1529
  }
1133
- var MIN_SUGGESTION_SCORE = 5;
1134
- var MIN_MATCH_RATIO = 0.4;
1135
- function scoreEntity(entityName, contentTokens, contentStems) {
1530
+ var STRICTNESS_CONFIGS = {
1531
+ conservative: {
1532
+ minWordLength: 5,
1533
+ minSuggestionScore: 15,
1534
+ // Requires exact match (10) + at least one stem (5)
1535
+ minMatchRatio: 0.6,
1536
+ // 60% of multi-word entity must match
1537
+ requireMultipleMatches: true,
1538
+ // Single-word entities need multiple content matches
1539
+ stemMatchBonus: 3,
1540
+ // Lower bonus for stem-only matches
1541
+ exactMatchBonus: 10
1542
+ // Standard bonus for exact matches
1543
+ },
1544
+ balanced: {
1545
+ minWordLength: 4,
1546
+ minSuggestionScore: 8,
1547
+ // At least one exact match or two stem matches
1548
+ minMatchRatio: 0.4,
1549
+ // 40% of multi-word entity must match
1550
+ requireMultipleMatches: false,
1551
+ stemMatchBonus: 5,
1552
+ // Standard bonus for stem matches
1553
+ exactMatchBonus: 10
1554
+ // Standard bonus for exact matches
1555
+ },
1556
+ aggressive: {
1557
+ minWordLength: 4,
1558
+ minSuggestionScore: 5,
1559
+ // Single stem match is enough
1560
+ minMatchRatio: 0.3,
1561
+ // 30% of multi-word entity must match
1562
+ requireMultipleMatches: false,
1563
+ stemMatchBonus: 6,
1564
+ // Higher bonus for stem matches
1565
+ exactMatchBonus: 10
1566
+ // Standard bonus for exact matches
1567
+ }
1568
+ };
1569
+ var DEFAULT_STRICTNESS = "conservative";
1570
+ var MIN_SUGGESTION_SCORE = STRICTNESS_CONFIGS.balanced.minSuggestionScore;
1571
+ var MIN_MATCH_RATIO = STRICTNESS_CONFIGS.balanced.minMatchRatio;
1572
+ function scoreEntity(entityName, contentTokens, contentStems, config) {
1136
1573
  const entityTokens = tokenize(entityName);
1137
1574
  if (entityTokens.length === 0)
1138
1575
  return 0;
1139
1576
  const entityStems = entityTokens.map((t) => stem(t));
1140
1577
  let score = 0;
1141
1578
  let matchedWords = 0;
1579
+ let exactMatches = 0;
1142
1580
  for (let i = 0; i < entityTokens.length; i++) {
1143
1581
  const token = entityTokens[i];
1144
1582
  const entityStem = entityStems[i];
1145
1583
  if (contentTokens.has(token)) {
1146
- score += 10;
1584
+ score += config.exactMatchBonus;
1147
1585
  matchedWords++;
1586
+ exactMatches++;
1148
1587
  } else if (contentStems.has(entityStem)) {
1149
- score += 5;
1588
+ score += config.stemMatchBonus;
1150
1589
  matchedWords++;
1151
1590
  }
1152
1591
  }
1153
1592
  if (entityTokens.length > 1) {
1154
1593
  const matchRatio = matchedWords / entityTokens.length;
1155
- if (matchRatio < MIN_MATCH_RATIO) {
1594
+ if (matchRatio < config.minMatchRatio) {
1595
+ return 0;
1596
+ }
1597
+ }
1598
+ if (config.requireMultipleMatches && entityTokens.length === 1) {
1599
+ if (exactMatches === 0) {
1156
1600
  return 0;
1157
1601
  }
1158
1602
  }
1159
1603
  return score;
1160
1604
  }
1161
1605
  function suggestRelatedLinks(content, options = {}) {
1162
- const { maxSuggestions = 3, excludeLinked = true } = options;
1606
+ const {
1607
+ maxSuggestions = 3,
1608
+ excludeLinked = true,
1609
+ strictness = DEFAULT_STRICTNESS
1610
+ } = options;
1611
+ const config = STRICTNESS_CONFIGS[strictness];
1163
1612
  const emptyResult = { suggestions: [], suffix: "" };
1164
1613
  if (SUGGESTION_PATTERN.test(content)) {
1165
1614
  return emptyResult;
@@ -1191,11 +1640,11 @@ function suggestRelatedLinks(content, options = {}) {
1191
1640
  if (linkedEntities.has(entityName.toLowerCase())) {
1192
1641
  continue;
1193
1642
  }
1194
- const score = scoreEntity(entityName, contentTokens, contentStems);
1643
+ const score = scoreEntity(entityName, contentTokens, contentStems, config);
1195
1644
  if (score > 0) {
1196
1645
  directlyMatchedEntities.add(entityName);
1197
1646
  }
1198
- if (score >= MIN_SUGGESTION_SCORE) {
1647
+ if (score >= config.minSuggestionScore) {
1199
1648
  scoredEntities.push({ name: entityName, score });
1200
1649
  }
1201
1650
  }
@@ -1215,7 +1664,7 @@ function suggestRelatedLinks(content, options = {}) {
1215
1664
  const existing = scoredEntities.find((e) => e.name === entityName);
1216
1665
  if (existing) {
1217
1666
  existing.score += boost;
1218
- } else if (boost >= MIN_SUGGESTION_SCORE) {
1667
+ } else if (boost >= config.minSuggestionScore) {
1219
1668
  scoredEntities.push({ name: entityName, score: boost });
1220
1669
  }
1221
1670
  }
@@ -1248,10 +1697,11 @@ function registerMutationTools(server2, vaultPath2) {
1248
1697
  format: z.enum(["plain", "bullet", "task", "numbered", "timestamp-bullet"]).default("plain").describe("How to format the content"),
1249
1698
  commit: z.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
1250
1699
  skipWikilinks: z.boolean().default(false).describe("If true, skip auto-wikilink application (wikilinks are applied by default)"),
1251
- preserveListNesting: z.boolean().default(false).describe("If true, detect and preserve the indentation level of surrounding list items"),
1252
- suggestOutgoingLinks: z.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]] [[Philosophy]]"). Set false to disable.')
1700
+ preserveListNesting: z.boolean().default(true).describe("Detect and preserve the indentation level of surrounding list items. Set false to disable."),
1701
+ suggestOutgoingLinks: z.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]] [[Philosophy]]"). Set false to disable.'),
1702
+ maxSuggestions: z.number().min(1).max(10).default(3).describe("Maximum number of suggested wikilinks to append (1-10, default: 3)")
1253
1703
  },
1254
- async ({ path: notePath, section, content, position, format, commit, skipWikilinks, preserveListNesting, suggestOutgoingLinks }) => {
1704
+ async ({ path: notePath, section, content, position, format, commit, skipWikilinks, preserveListNesting, suggestOutgoingLinks, maxSuggestions }) => {
1255
1705
  try {
1256
1706
  const fullPath = path5.join(vaultPath2, notePath);
1257
1707
  try {
@@ -1277,7 +1727,7 @@ function registerMutationTools(server2, vaultPath2) {
1277
1727
  let { content: processedContent, wikilinkInfo } = maybeApplyWikilinks(content, skipWikilinks);
1278
1728
  let suggestInfo;
1279
1729
  if (suggestOutgoingLinks && !skipWikilinks) {
1280
- const result2 = suggestRelatedLinks(processedContent);
1730
+ const result2 = suggestRelatedLinks(processedContent, { maxSuggestions });
1281
1731
  if (result2.suffix) {
1282
1732
  processedContent = processedContent + " " + result2.suffix;
1283
1733
  suggestInfo = `Suggested: ${result2.suggestions.join(", ")}`;
@@ -1415,9 +1865,10 @@ function registerMutationTools(server2, vaultPath2) {
1415
1865
  useRegex: z.boolean().default(false).describe("Treat search as regex"),
1416
1866
  commit: z.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
1417
1867
  skipWikilinks: z.boolean().default(false).describe("If true, skip auto-wikilink application on replacement text"),
1418
- suggestOutgoingLinks: z.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]] [[Philosophy]]"). Set false to disable.')
1868
+ suggestOutgoingLinks: z.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]] [[Philosophy]]"). Set false to disable.'),
1869
+ maxSuggestions: z.number().min(1).max(10).default(3).describe("Maximum number of suggested wikilinks to append (1-10, default: 3)")
1419
1870
  },
1420
- async ({ path: notePath, section, search, replacement, mode, useRegex, commit, skipWikilinks, suggestOutgoingLinks }) => {
1871
+ async ({ path: notePath, section, search, replacement, mode, useRegex, commit, skipWikilinks, suggestOutgoingLinks, maxSuggestions }) => {
1421
1872
  try {
1422
1873
  const fullPath = path5.join(vaultPath2, notePath);
1423
1874
  try {
@@ -1443,7 +1894,7 @@ function registerMutationTools(server2, vaultPath2) {
1443
1894
  let { content: processedReplacement, wikilinkInfo } = maybeApplyWikilinks(replacement, skipWikilinks);
1444
1895
  let suggestInfo;
1445
1896
  if (suggestOutgoingLinks && !skipWikilinks) {
1446
- const result2 = suggestRelatedLinks(processedReplacement);
1897
+ const result2 = suggestRelatedLinks(processedReplacement, { maxSuggestions });
1447
1898
  if (result2.suffix) {
1448
1899
  processedReplacement = processedReplacement + " " + result2.suffix;
1449
1900
  suggestInfo = `Suggested: ${result2.suggestions.join(", ")}`;
@@ -1652,9 +2103,10 @@ function registerTaskTools(server2, vaultPath2) {
1652
2103
  commit: z2.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
1653
2104
  skipWikilinks: z2.boolean().default(false).describe("If true, skip auto-wikilink application (wikilinks are applied by default)"),
1654
2105
  suggestOutgoingLinks: z2.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]] [[Philosophy]]"). Set false to disable.'),
2106
+ maxSuggestions: z2.number().min(1).max(10).default(3).describe("Maximum number of suggested wikilinks to append (1-10, default: 3)"),
1655
2107
  preserveListNesting: z2.boolean().default(true).describe("Preserve indentation when inserting into nested lists. Default: true")
1656
2108
  },
1657
- async ({ path: notePath, section, task, position, completed, commit, skipWikilinks, suggestOutgoingLinks, preserveListNesting }) => {
2109
+ async ({ path: notePath, section, task, position, completed, commit, skipWikilinks, suggestOutgoingLinks, maxSuggestions, preserveListNesting }) => {
1658
2110
  try {
1659
2111
  const fullPath = path6.join(vaultPath2, notePath);
1660
2112
  try {
@@ -1680,7 +2132,7 @@ function registerTaskTools(server2, vaultPath2) {
1680
2132
  let { content: processedTask, wikilinkInfo } = maybeApplyWikilinks(task.trim(), skipWikilinks);
1681
2133
  let suggestInfo;
1682
2134
  if (suggestOutgoingLinks && !skipWikilinks) {
1683
- const result2 = suggestRelatedLinks(processedTask);
2135
+ const result2 = suggestRelatedLinks(processedTask, { maxSuggestions });
1684
2136
  if (result2.suffix) {
1685
2137
  processedTask = processedTask + " " + result2.suffix;
1686
2138
  suggestInfo = `Suggested: ${result2.suggestions.join(", ")}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-crank",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Deterministic vault mutations for Obsidian via MCP",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",