@velvetmonkey/flywheel-crank 0.4.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 +713 -132
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -16,6 +16,61 @@ import matter from "gray-matter";
16
16
  var HEADING_REGEX = /^(#{1,6})\s+(.+)$/;
17
17
 
18
18
  // src/core/writer.ts
19
+ var SENSITIVE_PATH_PATTERNS = [
20
+ /\.env($|\..*)/i,
21
+ // .env, .env.local, .env.production, etc.
22
+ /\.git\/config$/i,
23
+ // Git config (may contain tokens)
24
+ /\.git\/credentials$/i,
25
+ // Git credentials
26
+ /\.pem$/i,
27
+ // SSL/TLS certificates
28
+ /\.key$/i,
29
+ // Private keys
30
+ /\.p12$/i,
31
+ // PKCS#12 certificates
32
+ /\.pfx$/i,
33
+ // Windows certificate format
34
+ /\.jks$/i,
35
+ // Java keystore
36
+ /id_rsa/i,
37
+ // SSH private key
38
+ /id_ed25519/i,
39
+ // SSH private key (ed25519)
40
+ /id_ecdsa/i,
41
+ // SSH private key (ecdsa)
42
+ /credentials\.json$/i,
43
+ // Cloud credentials files
44
+ /secrets\.json$/i,
45
+ // Secrets files
46
+ /secrets\.ya?ml$/i,
47
+ // Secrets YAML files
48
+ /\.htpasswd$/i,
49
+ // Apache password file
50
+ /shadow$/,
51
+ // Unix shadow password file
52
+ /passwd$/
53
+ // Unix password file
54
+ ];
55
+ function isSensitivePath(filePath) {
56
+ const normalizedPath = filePath.replace(/\\/g, "/");
57
+ return SENSITIVE_PATH_PATTERNS.some((pattern) => pattern.test(normalizedPath));
58
+ }
59
+ function detectLineEnding(content) {
60
+ const crlfCount = (content.match(/\r\n/g) || []).length;
61
+ const lfCount = (content.match(/(?<!\r)\n/g) || []).length;
62
+ return crlfCount > lfCount ? "CRLF" : "LF";
63
+ }
64
+ function normalizeLineEndings(content) {
65
+ return content.replace(/\r\n/g, "\n");
66
+ }
67
+ function convertLineEndings(content, style) {
68
+ const normalized = content.replace(/\r\n/g, "\n");
69
+ return style === "CRLF" ? normalized.replace(/\n/g, "\r\n") : normalized;
70
+ }
71
+ function normalizeTrailingNewline(content) {
72
+ return content.replace(/[\r\n\s]+$/, "") + "\n";
73
+ }
19
74
  var EMPTY_PLACEHOLDER_PATTERNS = [
20
75
  /^\d+\.\s*$/,
21
76
  // "1. " or "2. " (numbered list placeholder)
@@ -169,25 +224,88 @@ function validatePath(vaultPath2, notePath) {
169
224
  const resolvedNote = path.resolve(vaultPath2, notePath);
170
225
  return resolvedNote.startsWith(resolvedVault);
171
226
  }
227
+ async function validatePathSecure(vaultPath2, notePath) {
228
+ const resolvedVault = path.resolve(vaultPath2);
229
+ const resolvedNote = path.resolve(vaultPath2, notePath);
230
+ if (!resolvedNote.startsWith(resolvedVault)) {
231
+ return {
232
+ valid: false,
233
+ reason: "Path traversal not allowed"
234
+ };
235
+ }
236
+ if (isSensitivePath(notePath)) {
237
+ return {
238
+ valid: false,
239
+ reason: "Cannot write to sensitive file (credentials, keys, secrets)"
240
+ };
241
+ }
242
+ try {
243
+ const fullPath = path.join(vaultPath2, notePath);
244
+ try {
245
+ await fs.access(fullPath);
246
+ const realPath = await fs.realpath(fullPath);
247
+ const realVaultPath = await fs.realpath(vaultPath2);
248
+ if (!realPath.startsWith(realVaultPath)) {
249
+ return {
250
+ valid: false,
251
+ reason: "Symlink target is outside vault"
252
+ };
253
+ }
254
+ const relativePath = path.relative(realVaultPath, realPath);
255
+ if (isSensitivePath(relativePath)) {
256
+ return {
257
+ valid: false,
258
+ reason: "Symlink target is a sensitive file"
259
+ };
260
+ }
261
+ } catch {
262
+ const parentDir = path.dirname(fullPath);
263
+ try {
264
+ await fs.access(parentDir);
265
+ const realParentPath = await fs.realpath(parentDir);
266
+ const realVaultPath = await fs.realpath(vaultPath2);
267
+ if (!realParentPath.startsWith(realVaultPath)) {
268
+ return {
269
+ valid: false,
270
+ reason: "Parent directory symlink target is outside vault"
271
+ };
272
+ }
273
+ } catch {
274
+ }
275
+ }
276
+ } catch (error) {
277
+ return {
278
+ valid: false,
279
+ reason: `Path validation error: ${error.message}`
280
+ };
281
+ }
282
+ return { valid: true };
283
+ }
172
284
  async function readVaultFile(vaultPath2, notePath) {
173
285
  if (!validatePath(vaultPath2, notePath)) {
174
286
  throw new Error("Invalid path: path traversal not allowed");
175
287
  }
176
288
  const fullPath = path.join(vaultPath2, notePath);
177
289
  const rawContent = await fs.readFile(fullPath, "utf-8");
178
- const parsed = matter(rawContent);
290
+ const lineEnding = detectLineEnding(rawContent);
291
+ const normalizedContent = normalizeLineEndings(rawContent);
292
+ const parsed = matter(normalizedContent);
179
293
  return {
180
294
  content: parsed.content,
181
295
  frontmatter: parsed.data,
182
- rawContent
296
+ rawContent,
297
+ lineEnding
183
298
  };
184
299
  }
185
- async function writeVaultFile(vaultPath2, notePath, content, frontmatter) {
186
- if (!validatePath(vaultPath2, notePath)) {
187
- throw new Error("Invalid path: path traversal not allowed");
300
+ async function writeVaultFile(vaultPath2, notePath, content, frontmatter, lineEnding = "LF") {
301
+ const validation = await validatePathSecure(vaultPath2, notePath);
302
+ if (!validation.valid) {
303
+ throw new Error(`Invalid path: ${validation.reason}`);
188
304
  }
189
305
  const fullPath = path.join(vaultPath2, notePath);
190
- const output = matter.stringify(content, frontmatter);
306
+ let output = matter.stringify(content, frontmatter);
307
+ output = normalizeTrailingNewline(output);
308
+ output = convertLineEndings(output, lineEnding);
191
309
  await fs.writeFile(fullPath, output, "utf-8");
192
310
  }
193
311
  function removeFromSection(content, section, pattern, mode = "first", useRegex = false) {
@@ -374,10 +492,498 @@ import {
374
492
  loadEntityCache,
375
493
  saveEntityCache
376
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";
377
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
378
983
  var entityIndex = null;
379
984
  var indexReady = false;
380
985
  var indexError = null;
986
+ var cooccurrenceIndex = null;
381
987
  var DEFAULT_EXCLUDE_FOLDERS = [
382
988
  "daily-notes",
383
989
  "daily",
@@ -390,7 +996,7 @@ var DEFAULT_EXCLUDE_FOLDERS = [
390
996
  "templates"
391
997
  ];
392
998
  async function initializeEntityIndex(vaultPath2) {
393
- const cacheFile = path3.join(vaultPath2, ".claude", "wikilink-entities.json");
999
+ const cacheFile = path4.join(vaultPath2, ".claude", "wikilink-entities.json");
394
1000
  try {
395
1001
  const cached = await loadEntityCache(cacheFile);
396
1002
  if (cached) {
@@ -416,8 +1022,17 @@ async function rebuildIndex(vaultPath2, cacheFile) {
416
1022
  excludeFolders: DEFAULT_EXCLUDE_FOLDERS
417
1023
  });
418
1024
  indexReady = true;
419
- const duration = Date.now() - startTime;
420
- 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
+ }
421
1036
  try {
422
1037
  await saveEntityCache(cacheFile, entityIndex);
423
1038
  console.error(`[Crank] Entity cache saved`);
@@ -461,86 +1076,6 @@ function maybeApplyWikilinks(content, skipWikilinks) {
461
1076
  return { content: result.content };
462
1077
  }
463
1078
  var SUGGESTION_PATTERN = /→\s*\[\[.+$/;
464
- var STOPWORDS = /* @__PURE__ */ new Set([
465
- "the",
466
- "a",
467
- "an",
468
- "and",
469
- "or",
470
- "but",
471
- "in",
472
- "on",
473
- "at",
474
- "to",
475
- "for",
476
- "of",
477
- "with",
478
- "by",
479
- "from",
480
- "as",
481
- "is",
482
- "was",
483
- "are",
484
- "were",
485
- "been",
486
- "be",
487
- "have",
488
- "has",
489
- "had",
490
- "do",
491
- "does",
492
- "did",
493
- "will",
494
- "would",
495
- "could",
496
- "should",
497
- "may",
498
- "might",
499
- "must",
500
- "shall",
501
- "can",
502
- "need",
503
- "this",
504
- "that",
505
- "these",
506
- "those",
507
- "i",
508
- "you",
509
- "he",
510
- "she",
511
- "it",
512
- "we",
513
- "they",
514
- "what",
515
- "which",
516
- "who",
517
- "whom",
518
- "when",
519
- "where",
520
- "why",
521
- "how",
522
- "all",
523
- "each",
524
- "every",
525
- "both",
526
- "few",
527
- "more",
528
- "most",
529
- "other",
530
- "some",
531
- "such",
532
- "no",
533
- "not",
534
- "only",
535
- "own",
536
- "same",
537
- "so",
538
- "than",
539
- "too",
540
- "very",
541
- "just",
542
- "also"
543
- ]);
544
1079
  function extractLinkedEntities(content) {
545
1080
  const linked = /* @__PURE__ */ new Set();
546
1081
  const wikilinkRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
@@ -550,23 +1085,37 @@ function extractLinkedEntities(content) {
550
1085
  }
551
1086
  return linked;
552
1087
  }
553
- function tokenizeContent(content) {
554
- const cleanContent = content.replace(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g, "$1").replace(/[*_`#\[\]()]/g, " ").toLowerCase();
555
- const words = cleanContent.match(/\b[a-z]{4,}\b/g) || [];
556
- 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 };
557
1093
  }
558
- function scoreEntity(entityName, contentTokens) {
559
- 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));
560
1102
  let score = 0;
561
- for (const entityWord of entityWords) {
562
- if (entityWord.length < 3)
563
- continue;
564
- for (const contentToken of contentTokens) {
565
- if (contentToken === entityWord) {
566
- score += 3;
567
- } else if (contentToken.includes(entityWord) || entityWord.includes(contentToken)) {
568
- score += 1;
569
- }
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;
570
1119
  }
571
1120
  }
572
1121
  return score;
@@ -584,19 +1133,49 @@ function suggestRelatedLinks(content, options = {}) {
584
1133
  if (entities.length === 0) {
585
1134
  return emptyResult;
586
1135
  }
587
- const contentTokens = tokenizeContent(content);
588
- if (contentTokens.length === 0) {
1136
+ const { tokens: contentTokens, stems: contentStems } = tokenizeForMatching(content);
1137
+ if (contentTokens.size === 0) {
589
1138
  return emptyResult;
590
1139
  }
591
1140
  const linkedEntities = excludeLinked ? extractLinkedEntities(content) : /* @__PURE__ */ new Set();
592
1141
  const scoredEntities = [];
1142
+ const directlyMatchedEntities = /* @__PURE__ */ new Set();
593
1143
  for (const entity of entities) {
594
- if (linkedEntities.has(entity.name.toLowerCase())) {
1144
+ const entityName = typeof entity === "string" ? entity : entity.name;
1145
+ if (!entityName)
1146
+ continue;
1147
+ if (entityName.length > MAX_ENTITY_LENGTH) {
595
1148
  continue;
596
1149
  }
597
- const score = scoreEntity(entity.name, contentTokens);
1150
+ if (linkedEntities.has(entityName.toLowerCase())) {
1151
+ continue;
1152
+ }
1153
+ const score = scoreEntity(entityName, contentTokens, contentStems);
598
1154
  if (score > 0) {
599
- scoredEntities.push({ name: entity.name, score });
1155
+ directlyMatchedEntities.add(entityName);
1156
+ }
1157
+ if (score >= MIN_SUGGESTION_SCORE) {
1158
+ scoredEntities.push({ name: entityName, score });
1159
+ }
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
+ }
600
1179
  }
601
1180
  }
602
1181
  scoredEntities.sort((a, b) => b.score - a.score);
@@ -613,7 +1192,7 @@ function suggestRelatedLinks(content, options = {}) {
613
1192
 
614
1193
  // src/tools/mutations.ts
615
1194
  import fs2 from "fs/promises";
616
- import path4 from "path";
1195
+ import path5 from "path";
617
1196
  function registerMutationTools(server2, vaultPath2) {
618
1197
  server2.tool(
619
1198
  "vault_add_to_section",
@@ -631,7 +1210,7 @@ function registerMutationTools(server2, vaultPath2) {
631
1210
  },
632
1211
  async ({ path: notePath, section, content, position, format, commit, skipWikilinks, preserveListNesting, suggestOutgoingLinks }) => {
633
1212
  try {
634
- const fullPath = path4.join(vaultPath2, notePath);
1213
+ const fullPath = path5.join(vaultPath2, notePath);
635
1214
  try {
636
1215
  await fs2.access(fullPath);
637
1216
  } catch {
@@ -715,7 +1294,7 @@ function registerMutationTools(server2, vaultPath2) {
715
1294
  },
716
1295
  async ({ path: notePath, section, pattern, mode, useRegex, commit }) => {
717
1296
  try {
718
- const fullPath = path4.join(vaultPath2, notePath);
1297
+ const fullPath = path5.join(vaultPath2, notePath);
719
1298
  try {
720
1299
  await fs2.access(fullPath);
721
1300
  } catch {
@@ -797,7 +1376,7 @@ function registerMutationTools(server2, vaultPath2) {
797
1376
  },
798
1377
  async ({ path: notePath, section, search, replacement, mode, useRegex, commit, skipWikilinks, suggestOutgoingLinks }) => {
799
1378
  try {
800
- const fullPath = path4.join(vaultPath2, notePath);
1379
+ const fullPath = path5.join(vaultPath2, notePath);
801
1380
  try {
802
1381
  await fs2.access(fullPath);
803
1382
  } catch {
@@ -883,7 +1462,7 @@ function registerMutationTools(server2, vaultPath2) {
883
1462
  // src/tools/tasks.ts
884
1463
  import { z as z2 } from "zod";
885
1464
  import fs3 from "fs/promises";
886
- import path5 from "path";
1465
+ import path6 from "path";
887
1466
  var TASK_REGEX = /^(\s*)-\s*\[([ xX])\]\s*(.*)$/;
888
1467
  function findTasks(content, section) {
889
1468
  const lines = content.split("\n");
@@ -939,7 +1518,7 @@ function registerTaskTools(server2, vaultPath2) {
939
1518
  },
940
1519
  async ({ path: notePath, task, section, commit }) => {
941
1520
  try {
942
- const fullPath = path5.join(vaultPath2, notePath);
1521
+ const fullPath = path6.join(vaultPath2, notePath);
943
1522
  try {
944
1523
  await fs3.access(fullPath);
945
1524
  } catch {
@@ -1029,11 +1608,12 @@ function registerTaskTools(server2, vaultPath2) {
1029
1608
  completed: z2.boolean().default(false).describe("Whether the task should start as completed"),
1030
1609
  commit: z2.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
1031
1610
  skipWikilinks: z2.boolean().default(false).describe("If true, skip auto-wikilink application (wikilinks are applied by default)"),
1032
- suggestOutgoingLinks: z2.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]] [[Philosophy]]"). Set false to disable.')
1611
+ suggestOutgoingLinks: z2.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]] [[Philosophy]]"). Set false to disable.'),
1612
+ preserveListNesting: z2.boolean().default(true).describe("Preserve indentation when inserting into nested lists. Default: true")
1033
1613
  },
1034
- async ({ path: notePath, section, task, position, completed, commit, skipWikilinks, suggestOutgoingLinks }) => {
1614
+ async ({ path: notePath, section, task, position, completed, commit, skipWikilinks, suggestOutgoingLinks, preserveListNesting }) => {
1035
1615
  try {
1036
- const fullPath = path5.join(vaultPath2, notePath);
1616
+ const fullPath = path6.join(vaultPath2, notePath);
1037
1617
  try {
1038
1618
  await fs3.access(fullPath);
1039
1619
  } catch {
@@ -1069,7 +1649,8 @@ function registerTaskTools(server2, vaultPath2) {
1069
1649
  fileContent,
1070
1650
  sectionBoundary,
1071
1651
  taskLine,
1072
- position
1652
+ position,
1653
+ { preserveListNesting }
1073
1654
  );
1074
1655
  await writeVaultFile(vaultPath2, notePath, updatedContent, frontmatter);
1075
1656
  let gitCommit;
@@ -1109,7 +1690,7 @@ function registerTaskTools(server2, vaultPath2) {
1109
1690
  // src/tools/frontmatter.ts
1110
1691
  import { z as z3 } from "zod";
1111
1692
  import fs4 from "fs/promises";
1112
- import path6 from "path";
1693
+ import path7 from "path";
1113
1694
  function registerFrontmatterTools(server2, vaultPath2) {
1114
1695
  server2.tool(
1115
1696
  "vault_update_frontmatter",
@@ -1121,7 +1702,7 @@ function registerFrontmatterTools(server2, vaultPath2) {
1121
1702
  },
1122
1703
  async ({ path: notePath, frontmatter: updates, commit }) => {
1123
1704
  try {
1124
- const fullPath = path6.join(vaultPath2, notePath);
1705
+ const fullPath = path7.join(vaultPath2, notePath);
1125
1706
  try {
1126
1707
  await fs4.access(fullPath);
1127
1708
  } catch {
@@ -1177,7 +1758,7 @@ function registerFrontmatterTools(server2, vaultPath2) {
1177
1758
  },
1178
1759
  async ({ path: notePath, key, value, commit }) => {
1179
1760
  try {
1180
- const fullPath = path6.join(vaultPath2, notePath);
1761
+ const fullPath = path7.join(vaultPath2, notePath);
1181
1762
  try {
1182
1763
  await fs4.access(fullPath);
1183
1764
  } catch {
@@ -1234,7 +1815,7 @@ function registerFrontmatterTools(server2, vaultPath2) {
1234
1815
  // src/tools/notes.ts
1235
1816
  import { z as z4 } from "zod";
1236
1817
  import fs5 from "fs/promises";
1237
- import path7 from "path";
1818
+ import path8 from "path";
1238
1819
  function registerNoteTools(server2, vaultPath2) {
1239
1820
  server2.tool(
1240
1821
  "vault_create_note",
@@ -1256,7 +1837,7 @@ function registerNoteTools(server2, vaultPath2) {
1256
1837
  };
1257
1838
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1258
1839
  }
1259
- const fullPath = path7.join(vaultPath2, notePath);
1840
+ const fullPath = path8.join(vaultPath2, notePath);
1260
1841
  try {
1261
1842
  await fs5.access(fullPath);
1262
1843
  if (!overwrite) {
@@ -1269,7 +1850,7 @@ function registerNoteTools(server2, vaultPath2) {
1269
1850
  }
1270
1851
  } catch {
1271
1852
  }
1272
- const dir = path7.dirname(fullPath);
1853
+ const dir = path8.dirname(fullPath);
1273
1854
  await fs5.mkdir(dir, { recursive: true });
1274
1855
  await writeVaultFile(vaultPath2, notePath, content, frontmatter);
1275
1856
  let gitCommit;
@@ -1328,7 +1909,7 @@ Content length: ${content.length} chars`,
1328
1909
  };
1329
1910
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1330
1911
  }
1331
- const fullPath = path7.join(vaultPath2, notePath);
1912
+ const fullPath = path8.join(vaultPath2, notePath);
1332
1913
  try {
1333
1914
  await fs5.access(fullPath);
1334
1915
  } catch {
@@ -1374,7 +1955,7 @@ Content length: ${content.length} chars`,
1374
1955
  // src/tools/system.ts
1375
1956
  import { z as z5 } from "zod";
1376
1957
  import fs6 from "fs/promises";
1377
- import path8 from "path";
1958
+ import path9 from "path";
1378
1959
  function registerSystemTools(server2, vaultPath2) {
1379
1960
  server2.tool(
1380
1961
  "vault_list_sections",
@@ -1394,7 +1975,7 @@ function registerSystemTools(server2, vaultPath2) {
1394
1975
  };
1395
1976
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1396
1977
  }
1397
- const fullPath = path8.join(vaultPath2, notePath);
1978
+ const fullPath = path9.join(vaultPath2, notePath);
1398
1979
  try {
1399
1980
  await fs6.access(fullPath);
1400
1981
  } catch {
@@ -1502,18 +2083,18 @@ Message: ${undoResult.undoneCommit.message}` : void 0
1502
2083
 
1503
2084
  // src/core/vaultRoot.ts
1504
2085
  import * as fs7 from "fs";
1505
- import * as path9 from "path";
2086
+ import * as path10 from "path";
1506
2087
  var VAULT_MARKERS = [".obsidian", ".claude"];
1507
2088
  function findVaultRoot(startPath) {
1508
- let current = path9.resolve(startPath || process.cwd());
2089
+ let current = path10.resolve(startPath || process.cwd());
1509
2090
  while (true) {
1510
2091
  for (const marker of VAULT_MARKERS) {
1511
- const markerPath = path9.join(current, marker);
2092
+ const markerPath = path10.join(current, marker);
1512
2093
  if (fs7.existsSync(markerPath) && fs7.statSync(markerPath).isDirectory()) {
1513
2094
  return current;
1514
2095
  }
1515
2096
  }
1516
- const parent = path9.dirname(current);
2097
+ const parent = path10.dirname(current);
1517
2098
  if (parent === current) {
1518
2099
  return startPath || process.cwd();
1519
2100
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-crank",
3
- "version": "0.4.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",