@velvetmonkey/flywheel-crank 0.3.1 → 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 +230 -20
- 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,7 +134,13 @@ 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
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
|
-
|
|
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
|
-
|
|
475
|
-
|
|
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
|
|
494
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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} ${
|
|
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 + (
|
|
881
|
-
(${
|
|
1090
|
+
preview: taskLine + (infoLines.length > 0 ? `
|
|
1091
|
+
(${infoLines.join("; ")})` : ""),
|
|
882
1092
|
gitCommit,
|
|
883
1093
|
gitError
|
|
884
1094
|
};
|