@velvetmonkey/flywheel-crank 0.3.1 → 0.4.1

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 +230 -20
  2. package/package.json +12 -7
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,7 +134,13 @@ 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
145
  let insertLine;
123
146
  if (lastContentLineIdx >= section.contentStartLine) {
@@ -130,7 +153,13 @@ function insertInSection(content, section, newContent, position) {
130
153
  } else {
131
154
  insertLine = section.contentStartLine;
132
155
  }
133
- 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
+ }
134
163
  }
135
164
  }
136
165
  return lines.join("\n");
@@ -431,6 +460,156 @@ function maybeApplyWikilinks(content, skipWikilinks) {
431
460
  }
432
461
  return { content: result.content };
433
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
+ }
434
613
 
435
614
  // src/tools/mutations.ts
436
615
  import fs2 from "fs/promises";
@@ -446,9 +625,11 @@ function registerMutationTools(server2, vaultPath2) {
446
625
  position: z.enum(["append", "prepend"]).default("append").describe("Where to insert content"),
447
626
  format: z.enum(["plain", "bullet", "task", "numbered", "timestamp-bullet"]).default("plain").describe("How to format the content"),
448
627
  commit: z.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
449
- 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.')
450
631
  },
451
- async ({ path: notePath, section, content, position, format, commit, skipWikilinks }) => {
632
+ async ({ path: notePath, section, content, position, format, commit, skipWikilinks, preserveListNesting, suggestOutgoingLinks }) => {
452
633
  try {
453
634
  const fullPath = path4.join(vaultPath2, notePath);
454
635
  try {
@@ -471,13 +652,22 @@ function registerMutationTools(server2, vaultPath2) {
471
652
  };
472
653
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
473
654
  }
474
- const { content: wikilinkedContent, wikilinkInfo } = maybeApplyWikilinks(content, skipWikilinks);
475
- 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);
476
665
  const updatedContent = insertInSection(
477
666
  fileContent,
478
667
  sectionBoundary,
479
668
  formattedContent,
480
- position
669
+ position,
670
+ { preserveListNesting }
481
671
  );
482
672
  await writeVaultFile(vaultPath2, notePath, updatedContent, frontmatter);
483
673
  let gitCommit;
@@ -490,8 +680,9 @@ function registerMutationTools(server2, vaultPath2) {
490
680
  gitError = gitResult.error;
491
681
  }
492
682
  }
493
- const preview = formattedContent + (wikilinkInfo ? `
494
- (${wikilinkInfo})` : "");
683
+ const infoLines = [wikilinkInfo, suggestInfo].filter(Boolean);
684
+ const preview = formattedContent + (infoLines.length > 0 ? `
685
+ (${infoLines.join("; ")})` : "");
495
686
  const result = {
496
687
  success: true,
497
688
  message: `Added content to section "${sectionBoundary.name}" in ${notePath}`,
@@ -601,9 +792,10 @@ function registerMutationTools(server2, vaultPath2) {
601
792
  mode: z.enum(["first", "last", "all"]).default("first").describe("Which matches to replace"),
602
793
  useRegex: z.boolean().default(false).describe("Treat search as regex"),
603
794
  commit: z.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
604
- 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.')
605
797
  },
606
- async ({ path: notePath, section, search, replacement, mode, useRegex, commit, skipWikilinks }) => {
798
+ async ({ path: notePath, section, search, replacement, mode, useRegex, commit, skipWikilinks, suggestOutgoingLinks }) => {
607
799
  try {
608
800
  const fullPath = path4.join(vaultPath2, notePath);
609
801
  try {
@@ -626,12 +818,20 @@ function registerMutationTools(server2, vaultPath2) {
626
818
  };
627
819
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
628
820
  }
629
- 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
+ }
630
830
  const replaceResult = replaceInSection(
631
831
  fileContent,
632
832
  sectionBoundary,
633
833
  search,
634
- wikilinkedReplacement,
834
+ processedReplacement,
635
835
  mode,
636
836
  useRegex
637
837
  );
@@ -828,9 +1028,10 @@ function registerTaskTools(server2, vaultPath2) {
828
1028
  position: z2.enum(["append", "prepend"]).default("append").describe("Where to add the task"),
829
1029
  completed: z2.boolean().default(false).describe("Whether the task should start as completed"),
830
1030
  commit: z2.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
831
- 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.')
832
1033
  },
833
- async ({ path: notePath, section, task, position, completed, commit, skipWikilinks }) => {
1034
+ async ({ path: notePath, section, task, position, completed, commit, skipWikilinks, suggestOutgoingLinks }) => {
834
1035
  try {
835
1036
  const fullPath = path5.join(vaultPath2, notePath);
836
1037
  try {
@@ -853,9 +1054,17 @@ function registerTaskTools(server2, vaultPath2) {
853
1054
  };
854
1055
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
855
1056
  }
856
- 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
+ }
857
1066
  const checkbox = completed ? "[x]" : "[ ]";
858
- const taskLine = `- ${checkbox} ${wikilinkedTask}`;
1067
+ const taskLine = `- ${checkbox} ${processedTask}`;
859
1068
  const updatedContent = insertInSection(
860
1069
  fileContent,
861
1070
  sectionBoundary,
@@ -873,12 +1082,13 @@ function registerTaskTools(server2, vaultPath2) {
873
1082
  gitError = gitResult.error;
874
1083
  }
875
1084
  }
1085
+ const infoLines = [wikilinkInfo, suggestInfo].filter(Boolean);
876
1086
  const result = {
877
1087
  success: true,
878
1088
  message: `Added task to section "${sectionBoundary.name}" in ${notePath}`,
879
1089
  path: notePath,
880
- preview: taskLine + (wikilinkInfo ? `
881
- (${wikilinkInfo})` : ""),
1090
+ preview: taskLine + (infoLines.length > 0 ? `
1091
+ (${infoLines.join("; ")})` : ""),
882
1092
  gitCommit,
883
1093
  gitError
884
1094
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-crank",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Deterministic vault mutations for Obsidian via MCP",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -17,13 +17,18 @@
17
17
  "homepage": "https://github.com/velvetmonkey/flywheel-crank#readme",
18
18
  "author": "velvetmonkey",
19
19
  "keywords": [
20
- "obsidian",
21
20
  "mcp",
22
- "model-context-protocol",
21
+ "mcp-server",
22
+ "obsidian",
23
+ "vault-management",
24
+ "markdown",
23
25
  "claude",
24
- "mutations",
25
- "vault-writes",
26
- "markdown"
26
+ "deterministic",
27
+ "git-integration",
28
+ "wikilinks",
29
+ "knowledge-graph",
30
+ "ai-agents",
31
+ "mutation-tools"
27
32
  ],
28
33
  "scripts": {
29
34
  "build": "npx esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --packages=external && chmod +x dist/index.js",
@@ -50,7 +55,7 @@
50
55
  "engines": {
51
56
  "node": ">=18.0.0"
52
57
  },
53
- "license": "AGPL-3.0",
58
+ "license": "Apache-2.0",
54
59
  "files": [
55
60
  "dist",
56
61
  "README.md",