@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.
- package/dist/index.js +239 -24
- 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
|
|
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
|
-
|
|
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
|
|
123
|
-
if (
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
470
|
-
|
|
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
|
|
489
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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} ${
|
|
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 + (
|
|
876
|
-
(${
|
|
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.
|
|
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`);
|