@velvetmonkey/flywheel-crank 0.3.0 → 0.4.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 +239 -24
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -103,7 +103,24 @@ function formatContent(content, format) {
103
103
  return trimmed;
104
104
  }
105
105
  }
106
- function insertInSection(content, section, newContent, position) {
106
+ function detectListIndentation(lines, insertLineIndex, sectionStartLine) {
107
+ for (let i = insertLineIndex - 1; i >= sectionStartLine; i--) {
108
+ const line = lines[i];
109
+ const trimmed = line.trim();
110
+ if (trimmed === "")
111
+ continue;
112
+ const listMatch = line.match(/^(\s*)[-*+]\s|^(\s*)\d+\.\s|^(\s*)[-*+]\s*\[[ xX]\]/);
113
+ if (listMatch) {
114
+ const indent = listMatch[1] || listMatch[2] || listMatch[3] || "";
115
+ return indent;
116
+ }
117
+ if (trimmed.match(/^#+\s/)) {
118
+ break;
119
+ }
120
+ }
121
+ return "";
122
+ }
123
+ function insertInSection(content, section, newContent, position, options) {
107
124
  const lines = content.split("\n");
108
125
  const formattedContent = newContent.trim();
109
126
  if (position === "prepend") {
@@ -117,15 +134,32 @@ function insertInSection(content, section, newContent, position) {
117
134
  }
118
135
  }
119
136
  if (lastContentLineIdx >= section.contentStartLine && isEmptyPlaceholder(lines[lastContentLineIdx])) {
120
- lines[lastContentLineIdx] = formattedContent;
137
+ if (options?.preserveListNesting) {
138
+ const indent = detectListIndentation(lines, lastContentLineIdx, section.contentStartLine);
139
+ const indentedContent = formattedContent.split("\n").map((line) => indent + line).join("\n");
140
+ lines[lastContentLineIdx] = indentedContent;
141
+ } else {
142
+ lines[lastContentLineIdx] = formattedContent;
143
+ }
121
144
  } else {
122
- let insertLine = section.endLine + 1;
123
- if (section.contentStartLine <= section.endLine) {
124
- insertLine = section.endLine + 1;
145
+ let insertLine;
146
+ if (lastContentLineIdx >= section.contentStartLine) {
147
+ for (let i = section.endLine; i > lastContentLineIdx; i--) {
148
+ if (lines[i].trim() === "") {
149
+ lines.splice(i, 1);
150
+ }
151
+ }
152
+ insertLine = lastContentLineIdx + 1;
125
153
  } else {
126
154
  insertLine = section.contentStartLine;
127
155
  }
128
- lines.splice(insertLine, 0, formattedContent);
156
+ if (options?.preserveListNesting) {
157
+ const indent = detectListIndentation(lines, insertLine, section.contentStartLine);
158
+ const indentedContent = formattedContent.split("\n").map((line) => indent + line).join("\n");
159
+ lines.splice(insertLine, 0, indentedContent);
160
+ } else {
161
+ lines.splice(insertLine, 0, formattedContent);
162
+ }
129
163
  }
130
164
  }
131
165
  return lines.join("\n");
@@ -426,6 +460,156 @@ function maybeApplyWikilinks(content, skipWikilinks) {
426
460
  }
427
461
  return { content: result.content };
428
462
  }
463
+ 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
+ function extractLinkedEntities(content) {
545
+ const linked = /* @__PURE__ */ new Set();
546
+ const wikilinkRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
547
+ let match;
548
+ while ((match = wikilinkRegex.exec(content)) !== null) {
549
+ linked.add(match[1].toLowerCase());
550
+ }
551
+ return linked;
552
+ }
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));
557
+ }
558
+ function scoreEntity(entityName, contentTokens) {
559
+ const entityWords = entityName.toLowerCase().split(/\s+/);
560
+ 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
+ }
570
+ }
571
+ }
572
+ return score;
573
+ }
574
+ function suggestRelatedLinks(content, options = {}) {
575
+ const { maxSuggestions = 3, excludeLinked = true } = options;
576
+ const emptyResult = { suggestions: [], suffix: "" };
577
+ if (SUGGESTION_PATTERN.test(content)) {
578
+ return emptyResult;
579
+ }
580
+ if (!isEntityIndexReady() || !entityIndex) {
581
+ return emptyResult;
582
+ }
583
+ const entities = getAllEntities(entityIndex);
584
+ if (entities.length === 0) {
585
+ return emptyResult;
586
+ }
587
+ const contentTokens = tokenizeContent(content);
588
+ if (contentTokens.length === 0) {
589
+ return emptyResult;
590
+ }
591
+ const linkedEntities = excludeLinked ? extractLinkedEntities(content) : /* @__PURE__ */ new Set();
592
+ const scoredEntities = [];
593
+ for (const entity of entities) {
594
+ if (linkedEntities.has(entity.name.toLowerCase())) {
595
+ continue;
596
+ }
597
+ const score = scoreEntity(entity.name, contentTokens);
598
+ if (score > 0) {
599
+ scoredEntities.push({ name: entity.name, score });
600
+ }
601
+ }
602
+ scoredEntities.sort((a, b) => b.score - a.score);
603
+ const topSuggestions = scoredEntities.slice(0, maxSuggestions).map((e) => e.name);
604
+ if (topSuggestions.length === 0) {
605
+ return emptyResult;
606
+ }
607
+ const suffix = "\u2192 " + topSuggestions.map((name) => `[[${name}]]`).join(" ");
608
+ return {
609
+ suggestions: topSuggestions,
610
+ suffix
611
+ };
612
+ }
429
613
 
430
614
  // src/tools/mutations.ts
431
615
  import fs2 from "fs/promises";
@@ -441,9 +625,11 @@ function registerMutationTools(server2, vaultPath2) {
441
625
  position: z.enum(["append", "prepend"]).default("append").describe("Where to insert content"),
442
626
  format: z.enum(["plain", "bullet", "task", "numbered", "timestamp-bullet"]).default("plain").describe("How to format the content"),
443
627
  commit: z.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
444
- skipWikilinks: z.boolean().default(false).describe("If true, skip auto-wikilink application (wikilinks are applied by default)")
628
+ skipWikilinks: z.boolean().default(false).describe("If true, skip auto-wikilink application (wikilinks are applied by default)"),
629
+ preserveListNesting: z.boolean().default(false).describe("If true, detect and preserve the indentation level of surrounding list items"),
630
+ suggestOutgoingLinks: z.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]] [[Philosophy]]"). Set false to disable.')
445
631
  },
446
- async ({ path: notePath, section, content, position, format, commit, skipWikilinks }) => {
632
+ async ({ path: notePath, section, content, position, format, commit, skipWikilinks, preserveListNesting, suggestOutgoingLinks }) => {
447
633
  try {
448
634
  const fullPath = path4.join(vaultPath2, notePath);
449
635
  try {
@@ -466,13 +652,22 @@ function registerMutationTools(server2, vaultPath2) {
466
652
  };
467
653
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
468
654
  }
469
- const { content: wikilinkedContent, wikilinkInfo } = maybeApplyWikilinks(content, skipWikilinks);
470
- const formattedContent = formatContent(wikilinkedContent, format);
655
+ let { content: processedContent, wikilinkInfo } = maybeApplyWikilinks(content, skipWikilinks);
656
+ let suggestInfo;
657
+ if (suggestOutgoingLinks && !skipWikilinks) {
658
+ const result2 = suggestRelatedLinks(processedContent);
659
+ if (result2.suffix) {
660
+ processedContent = processedContent + " " + result2.suffix;
661
+ suggestInfo = `Suggested: ${result2.suggestions.join(", ")}`;
662
+ }
663
+ }
664
+ const formattedContent = formatContent(processedContent, format);
471
665
  const updatedContent = insertInSection(
472
666
  fileContent,
473
667
  sectionBoundary,
474
668
  formattedContent,
475
- position
669
+ position,
670
+ { preserveListNesting }
476
671
  );
477
672
  await writeVaultFile(vaultPath2, notePath, updatedContent, frontmatter);
478
673
  let gitCommit;
@@ -485,8 +680,9 @@ function registerMutationTools(server2, vaultPath2) {
485
680
  gitError = gitResult.error;
486
681
  }
487
682
  }
488
- const preview = formattedContent + (wikilinkInfo ? `
489
- (${wikilinkInfo})` : "");
683
+ const infoLines = [wikilinkInfo, suggestInfo].filter(Boolean);
684
+ const preview = formattedContent + (infoLines.length > 0 ? `
685
+ (${infoLines.join("; ")})` : "");
490
686
  const result = {
491
687
  success: true,
492
688
  message: `Added content to section "${sectionBoundary.name}" in ${notePath}`,
@@ -596,9 +792,10 @@ function registerMutationTools(server2, vaultPath2) {
596
792
  mode: z.enum(["first", "last", "all"]).default("first").describe("Which matches to replace"),
597
793
  useRegex: z.boolean().default(false).describe("Treat search as regex"),
598
794
  commit: z.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
599
- skipWikilinks: z.boolean().default(false).describe("If true, skip auto-wikilink application on replacement text")
795
+ skipWikilinks: z.boolean().default(false).describe("If true, skip auto-wikilink application on replacement text"),
796
+ suggestOutgoingLinks: z.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]] [[Philosophy]]"). Set false to disable.')
600
797
  },
601
- async ({ path: notePath, section, search, replacement, mode, useRegex, commit, skipWikilinks }) => {
798
+ async ({ path: notePath, section, search, replacement, mode, useRegex, commit, skipWikilinks, suggestOutgoingLinks }) => {
602
799
  try {
603
800
  const fullPath = path4.join(vaultPath2, notePath);
604
801
  try {
@@ -621,12 +818,20 @@ function registerMutationTools(server2, vaultPath2) {
621
818
  };
622
819
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
623
820
  }
624
- const { content: wikilinkedReplacement, wikilinkInfo } = maybeApplyWikilinks(replacement, skipWikilinks);
821
+ let { content: processedReplacement, wikilinkInfo } = maybeApplyWikilinks(replacement, skipWikilinks);
822
+ let suggestInfo;
823
+ if (suggestOutgoingLinks && !skipWikilinks) {
824
+ const result2 = suggestRelatedLinks(processedReplacement);
825
+ if (result2.suffix) {
826
+ processedReplacement = processedReplacement + " " + result2.suffix;
827
+ suggestInfo = `Suggested: ${result2.suggestions.join(", ")}`;
828
+ }
829
+ }
625
830
  const replaceResult = replaceInSection(
626
831
  fileContent,
627
832
  sectionBoundary,
628
833
  search,
629
- wikilinkedReplacement,
834
+ processedReplacement,
630
835
  mode,
631
836
  useRegex
632
837
  );
@@ -823,9 +1028,10 @@ function registerTaskTools(server2, vaultPath2) {
823
1028
  position: z2.enum(["append", "prepend"]).default("append").describe("Where to add the task"),
824
1029
  completed: z2.boolean().default(false).describe("Whether the task should start as completed"),
825
1030
  commit: z2.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
826
- skipWikilinks: z2.boolean().default(false).describe("If true, skip auto-wikilink application (wikilinks are applied by default)")
1031
+ 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.')
827
1033
  },
828
- async ({ path: notePath, section, task, position, completed, commit, skipWikilinks }) => {
1034
+ async ({ path: notePath, section, task, position, completed, commit, skipWikilinks, suggestOutgoingLinks }) => {
829
1035
  try {
830
1036
  const fullPath = path5.join(vaultPath2, notePath);
831
1037
  try {
@@ -848,9 +1054,17 @@ function registerTaskTools(server2, vaultPath2) {
848
1054
  };
849
1055
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
850
1056
  }
851
- const { content: wikilinkedTask, wikilinkInfo } = maybeApplyWikilinks(task.trim(), skipWikilinks);
1057
+ let { content: processedTask, wikilinkInfo } = maybeApplyWikilinks(task.trim(), skipWikilinks);
1058
+ let suggestInfo;
1059
+ if (suggestOutgoingLinks && !skipWikilinks) {
1060
+ const result2 = suggestRelatedLinks(processedTask);
1061
+ if (result2.suffix) {
1062
+ processedTask = processedTask + " " + result2.suffix;
1063
+ suggestInfo = `Suggested: ${result2.suggestions.join(", ")}`;
1064
+ }
1065
+ }
852
1066
  const checkbox = completed ? "[x]" : "[ ]";
853
- const taskLine = `- ${checkbox} ${wikilinkedTask}`;
1067
+ const taskLine = `- ${checkbox} ${processedTask}`;
854
1068
  const updatedContent = insertInSection(
855
1069
  fileContent,
856
1070
  sectionBoundary,
@@ -868,12 +1082,13 @@ function registerTaskTools(server2, vaultPath2) {
868
1082
  gitError = gitResult.error;
869
1083
  }
870
1084
  }
1085
+ const infoLines = [wikilinkInfo, suggestInfo].filter(Boolean);
871
1086
  const result = {
872
1087
  success: true,
873
1088
  message: `Added task to section "${sectionBoundary.name}" in ${notePath}`,
874
1089
  path: notePath,
875
- preview: taskLine + (wikilinkInfo ? `
876
- (${wikilinkInfo})` : ""),
1090
+ preview: taskLine + (infoLines.length > 0 ? `
1091
+ (${infoLines.join("; ")})` : ""),
877
1092
  gitCommit,
878
1093
  gitError
879
1094
  };
@@ -1309,7 +1524,7 @@ function findVaultRoot(startPath) {
1309
1524
  // src/index.ts
1310
1525
  var server = new McpServer({
1311
1526
  name: "flywheel-crank",
1312
- version: "0.1.0"
1527
+ version: "0.3.0"
1313
1528
  });
1314
1529
  var vaultPath = process.env.PROJECT_PATH || findVaultRoot();
1315
1530
  console.error(`[Crank] Starting Flywheel Crank MCP server`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-crank",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Deterministic vault mutations for Obsidian via MCP",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",