@velvetmonkey/flywheel-crank 0.2.1 → 0.3.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 +175 -37
  2. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -16,6 +16,22 @@ import matter from "gray-matter";
16
16
  var HEADING_REGEX = /^(#{1,6})\s+(.+)$/;
17
17
 
18
18
  // src/core/writer.ts
19
+ var EMPTY_PLACEHOLDER_PATTERNS = [
20
+ /^\d+\.\s*$/,
21
+ // "1. " or "2. " (numbered list placeholder)
22
+ /^-\s*$/,
23
+ // "- " (bullet placeholder)
24
+ /^-\s*\[\s*\]\s*$/,
25
+ // "- [ ] " (empty task placeholder)
26
+ /^-\s*\[x\]\s*$/i,
27
+ // "- [x] " (completed task placeholder)
28
+ /^\*\s*$/
29
+ // "* " (asterisk bullet placeholder)
30
+ ];
31
+ function isEmptyPlaceholder(line) {
32
+ const trimmed = line.trim();
33
+ return EMPTY_PLACEHOLDER_PATTERNS.some((p) => p.test(trimmed));
34
+ }
19
35
  function extractHeadings(content) {
20
36
  const lines = content.split("\n");
21
37
  const headings = [];
@@ -93,13 +109,29 @@ function insertInSection(content, section, newContent, position) {
93
109
  if (position === "prepend") {
94
110
  lines.splice(section.contentStartLine, 0, formattedContent);
95
111
  } else {
96
- let insertLine = section.endLine + 1;
97
- if (section.contentStartLine <= section.endLine) {
98
- insertLine = section.endLine + 1;
112
+ let lastContentLineIdx = -1;
113
+ for (let i = section.endLine; i >= section.contentStartLine; i--) {
114
+ if (lines[i].trim() !== "") {
115
+ lastContentLineIdx = i;
116
+ break;
117
+ }
118
+ }
119
+ if (lastContentLineIdx >= section.contentStartLine && isEmptyPlaceholder(lines[lastContentLineIdx])) {
120
+ lines[lastContentLineIdx] = formattedContent;
99
121
  } else {
100
- insertLine = section.contentStartLine;
122
+ let insertLine;
123
+ if (lastContentLineIdx >= section.contentStartLine) {
124
+ for (let i = section.endLine; i > lastContentLineIdx; i--) {
125
+ if (lines[i].trim() === "") {
126
+ lines.splice(i, 1);
127
+ }
128
+ }
129
+ insertLine = lastContentLineIdx + 1;
130
+ } else {
131
+ insertLine = section.contentStartLine;
132
+ }
133
+ lines.splice(insertLine, 0, formattedContent);
101
134
  }
102
- lines.splice(insertLine, 0, formattedContent);
103
135
  }
104
136
  return lines.join("\n");
105
137
  }
@@ -305,9 +337,104 @@ async function undoLastCommit(vaultPath2) {
305
337
  }
306
338
  }
307
339
 
340
+ // src/core/wikilinks.ts
341
+ import {
342
+ scanVaultEntities,
343
+ getAllEntities,
344
+ applyWikilinks,
345
+ loadEntityCache,
346
+ saveEntityCache
347
+ } from "@velvetmonkey/vault-core";
348
+ import path3 from "path";
349
+ var entityIndex = null;
350
+ var indexReady = false;
351
+ var indexError = null;
352
+ var DEFAULT_EXCLUDE_FOLDERS = [
353
+ "daily-notes",
354
+ "daily",
355
+ "weekly",
356
+ "monthly",
357
+ "quarterly",
358
+ "periodic",
359
+ "journal",
360
+ "inbox",
361
+ "templates"
362
+ ];
363
+ async function initializeEntityIndex(vaultPath2) {
364
+ const cacheFile = path3.join(vaultPath2, ".claude", "wikilink-entities.json");
365
+ try {
366
+ const cached = await loadEntityCache(cacheFile);
367
+ if (cached) {
368
+ entityIndex = cached;
369
+ indexReady = true;
370
+ console.error(`[Crank] Loaded ${cached._metadata.total_entities} entities from cache`);
371
+ const cacheAge = Date.now() - new Date(cached._metadata.generated_at).getTime();
372
+ if (cacheAge > 60 * 60 * 1e3) {
373
+ rebuildIndexInBackground(vaultPath2, cacheFile);
374
+ }
375
+ return;
376
+ }
377
+ await rebuildIndex(vaultPath2, cacheFile);
378
+ } catch (error) {
379
+ indexError = error instanceof Error ? error : new Error(String(error));
380
+ console.error(`[Crank] Failed to initialize entity index: ${indexError.message}`);
381
+ }
382
+ }
383
+ async function rebuildIndex(vaultPath2, cacheFile) {
384
+ console.error(`[Crank] Scanning vault for entities...`);
385
+ const startTime = Date.now();
386
+ entityIndex = await scanVaultEntities(vaultPath2, {
387
+ excludeFolders: DEFAULT_EXCLUDE_FOLDERS
388
+ });
389
+ indexReady = true;
390
+ const duration = Date.now() - startTime;
391
+ console.error(`[Crank] Entity index built: ${entityIndex._metadata.total_entities} entities in ${duration}ms`);
392
+ try {
393
+ await saveEntityCache(cacheFile, entityIndex);
394
+ console.error(`[Crank] Entity cache saved`);
395
+ } catch (e) {
396
+ console.error(`[Crank] Failed to save entity cache: ${e}`);
397
+ }
398
+ }
399
+ function rebuildIndexInBackground(vaultPath2, cacheFile) {
400
+ rebuildIndex(vaultPath2, cacheFile).catch((error) => {
401
+ console.error(`[Crank] Background index rebuild failed: ${error}`);
402
+ });
403
+ }
404
+ function isEntityIndexReady() {
405
+ return indexReady && entityIndex !== null;
406
+ }
407
+ function processWikilinks(content) {
408
+ if (!isEntityIndexReady() || !entityIndex) {
409
+ return {
410
+ content,
411
+ linksAdded: 0,
412
+ linkedEntities: []
413
+ };
414
+ }
415
+ const entities = getAllEntities(entityIndex);
416
+ return applyWikilinks(content, entities, {
417
+ firstOccurrenceOnly: true,
418
+ caseInsensitive: true
419
+ });
420
+ }
421
+ function maybeApplyWikilinks(content, skipWikilinks) {
422
+ if (skipWikilinks) {
423
+ return { content };
424
+ }
425
+ const result = processWikilinks(content);
426
+ if (result.linksAdded > 0) {
427
+ return {
428
+ content: result.content,
429
+ wikilinkInfo: `Applied ${result.linksAdded} wikilink(s): ${result.linkedEntities.join(", ")}`
430
+ };
431
+ }
432
+ return { content: result.content };
433
+ }
434
+
308
435
  // src/tools/mutations.ts
309
436
  import fs2 from "fs/promises";
310
- import path3 from "path";
437
+ import path4 from "path";
311
438
  function registerMutationTools(server2, vaultPath2) {
312
439
  server2.tool(
313
440
  "vault_add_to_section",
@@ -318,11 +445,12 @@ function registerMutationTools(server2, vaultPath2) {
318
445
  content: z.string().describe("Content to add to the section"),
319
446
  position: z.enum(["append", "prepend"]).default("append").describe("Where to insert content"),
320
447
  format: z.enum(["plain", "bullet", "task", "numbered", "timestamp-bullet"]).default("plain").describe("How to format the content"),
321
- commit: z.boolean().default(false).describe("If true, commit this change to git (creates undo point)")
448
+ 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)")
322
450
  },
323
- async ({ path: notePath, section, content, position, format, commit }) => {
451
+ async ({ path: notePath, section, content, position, format, commit, skipWikilinks }) => {
324
452
  try {
325
- const fullPath = path3.join(vaultPath2, notePath);
453
+ const fullPath = path4.join(vaultPath2, notePath);
326
454
  try {
327
455
  await fs2.access(fullPath);
328
456
  } catch {
@@ -343,7 +471,8 @@ function registerMutationTools(server2, vaultPath2) {
343
471
  };
344
472
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
345
473
  }
346
- const formattedContent = formatContent(content, format);
474
+ const { content: wikilinkedContent, wikilinkInfo } = maybeApplyWikilinks(content, skipWikilinks);
475
+ const formattedContent = formatContent(wikilinkedContent, format);
347
476
  const updatedContent = insertInSection(
348
477
  fileContent,
349
478
  sectionBoundary,
@@ -361,7 +490,8 @@ function registerMutationTools(server2, vaultPath2) {
361
490
  gitError = gitResult.error;
362
491
  }
363
492
  }
364
- const preview = formattedContent;
493
+ const preview = formattedContent + (wikilinkInfo ? `
494
+ (${wikilinkInfo})` : "");
365
495
  const result = {
366
496
  success: true,
367
497
  message: `Added content to section "${sectionBoundary.name}" in ${notePath}`,
@@ -394,7 +524,7 @@ function registerMutationTools(server2, vaultPath2) {
394
524
  },
395
525
  async ({ path: notePath, section, pattern, mode, useRegex, commit }) => {
396
526
  try {
397
- const fullPath = path3.join(vaultPath2, notePath);
527
+ const fullPath = path4.join(vaultPath2, notePath);
398
528
  try {
399
529
  await fs2.access(fullPath);
400
530
  } catch {
@@ -470,11 +600,12 @@ function registerMutationTools(server2, vaultPath2) {
470
600
  replacement: z.string().describe("Text to replace with"),
471
601
  mode: z.enum(["first", "last", "all"]).default("first").describe("Which matches to replace"),
472
602
  useRegex: z.boolean().default(false).describe("Treat search as regex"),
473
- commit: z.boolean().default(false).describe("If true, commit this change to git (creates undo point)")
603
+ 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")
474
605
  },
475
- async ({ path: notePath, section, search, replacement, mode, useRegex, commit }) => {
606
+ async ({ path: notePath, section, search, replacement, mode, useRegex, commit, skipWikilinks }) => {
476
607
  try {
477
- const fullPath = path3.join(vaultPath2, notePath);
608
+ const fullPath = path4.join(vaultPath2, notePath);
478
609
  try {
479
610
  await fs2.access(fullPath);
480
611
  } catch {
@@ -495,11 +626,12 @@ function registerMutationTools(server2, vaultPath2) {
495
626
  };
496
627
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
497
628
  }
629
+ const { content: wikilinkedReplacement, wikilinkInfo } = maybeApplyWikilinks(replacement, skipWikilinks);
498
630
  const replaceResult = replaceInSection(
499
631
  fileContent,
500
632
  sectionBoundary,
501
633
  search,
502
- replacement,
634
+ wikilinkedReplacement,
503
635
  mode,
504
636
  useRegex
505
637
  );
@@ -551,7 +683,7 @@ function registerMutationTools(server2, vaultPath2) {
551
683
  // src/tools/tasks.ts
552
684
  import { z as z2 } from "zod";
553
685
  import fs3 from "fs/promises";
554
- import path4 from "path";
686
+ import path5 from "path";
555
687
  var TASK_REGEX = /^(\s*)-\s*\[([ xX])\]\s*(.*)$/;
556
688
  function findTasks(content, section) {
557
689
  const lines = content.split("\n");
@@ -607,7 +739,7 @@ function registerTaskTools(server2, vaultPath2) {
607
739
  },
608
740
  async ({ path: notePath, task, section, commit }) => {
609
741
  try {
610
- const fullPath = path4.join(vaultPath2, notePath);
742
+ const fullPath = path5.join(vaultPath2, notePath);
611
743
  try {
612
744
  await fs3.access(fullPath);
613
745
  } catch {
@@ -695,11 +827,12 @@ function registerTaskTools(server2, vaultPath2) {
695
827
  task: z2.string().describe("Task text (without checkbox)"),
696
828
  position: z2.enum(["append", "prepend"]).default("append").describe("Where to add the task"),
697
829
  completed: z2.boolean().default(false).describe("Whether the task should start as completed"),
698
- commit: z2.boolean().default(false).describe("If true, commit this change to git (creates undo point)")
830
+ 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)")
699
832
  },
700
- async ({ path: notePath, section, task, position, completed, commit }) => {
833
+ async ({ path: notePath, section, task, position, completed, commit, skipWikilinks }) => {
701
834
  try {
702
- const fullPath = path4.join(vaultPath2, notePath);
835
+ const fullPath = path5.join(vaultPath2, notePath);
703
836
  try {
704
837
  await fs3.access(fullPath);
705
838
  } catch {
@@ -720,8 +853,9 @@ function registerTaskTools(server2, vaultPath2) {
720
853
  };
721
854
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
722
855
  }
856
+ const { content: wikilinkedTask, wikilinkInfo } = maybeApplyWikilinks(task.trim(), skipWikilinks);
723
857
  const checkbox = completed ? "[x]" : "[ ]";
724
- const taskLine = `- ${checkbox} ${task.trim()}`;
858
+ const taskLine = `- ${checkbox} ${wikilinkedTask}`;
725
859
  const updatedContent = insertInSection(
726
860
  fileContent,
727
861
  sectionBoundary,
@@ -743,7 +877,8 @@ function registerTaskTools(server2, vaultPath2) {
743
877
  success: true,
744
878
  message: `Added task to section "${sectionBoundary.name}" in ${notePath}`,
745
879
  path: notePath,
746
- preview: taskLine,
880
+ preview: taskLine + (wikilinkInfo ? `
881
+ (${wikilinkInfo})` : ""),
747
882
  gitCommit,
748
883
  gitError
749
884
  };
@@ -764,7 +899,7 @@ function registerTaskTools(server2, vaultPath2) {
764
899
  // src/tools/frontmatter.ts
765
900
  import { z as z3 } from "zod";
766
901
  import fs4 from "fs/promises";
767
- import path5 from "path";
902
+ import path6 from "path";
768
903
  function registerFrontmatterTools(server2, vaultPath2) {
769
904
  server2.tool(
770
905
  "vault_update_frontmatter",
@@ -776,7 +911,7 @@ function registerFrontmatterTools(server2, vaultPath2) {
776
911
  },
777
912
  async ({ path: notePath, frontmatter: updates, commit }) => {
778
913
  try {
779
- const fullPath = path5.join(vaultPath2, notePath);
914
+ const fullPath = path6.join(vaultPath2, notePath);
780
915
  try {
781
916
  await fs4.access(fullPath);
782
917
  } catch {
@@ -832,7 +967,7 @@ function registerFrontmatterTools(server2, vaultPath2) {
832
967
  },
833
968
  async ({ path: notePath, key, value, commit }) => {
834
969
  try {
835
- const fullPath = path5.join(vaultPath2, notePath);
970
+ const fullPath = path6.join(vaultPath2, notePath);
836
971
  try {
837
972
  await fs4.access(fullPath);
838
973
  } catch {
@@ -889,7 +1024,7 @@ function registerFrontmatterTools(server2, vaultPath2) {
889
1024
  // src/tools/notes.ts
890
1025
  import { z as z4 } from "zod";
891
1026
  import fs5 from "fs/promises";
892
- import path6 from "path";
1027
+ import path7 from "path";
893
1028
  function registerNoteTools(server2, vaultPath2) {
894
1029
  server2.tool(
895
1030
  "vault_create_note",
@@ -911,7 +1046,7 @@ function registerNoteTools(server2, vaultPath2) {
911
1046
  };
912
1047
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
913
1048
  }
914
- const fullPath = path6.join(vaultPath2, notePath);
1049
+ const fullPath = path7.join(vaultPath2, notePath);
915
1050
  try {
916
1051
  await fs5.access(fullPath);
917
1052
  if (!overwrite) {
@@ -924,7 +1059,7 @@ function registerNoteTools(server2, vaultPath2) {
924
1059
  }
925
1060
  } catch {
926
1061
  }
927
- const dir = path6.dirname(fullPath);
1062
+ const dir = path7.dirname(fullPath);
928
1063
  await fs5.mkdir(dir, { recursive: true });
929
1064
  await writeVaultFile(vaultPath2, notePath, content, frontmatter);
930
1065
  let gitCommit;
@@ -983,7 +1118,7 @@ Content length: ${content.length} chars`,
983
1118
  };
984
1119
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
985
1120
  }
986
- const fullPath = path6.join(vaultPath2, notePath);
1121
+ const fullPath = path7.join(vaultPath2, notePath);
987
1122
  try {
988
1123
  await fs5.access(fullPath);
989
1124
  } catch {
@@ -1029,7 +1164,7 @@ Content length: ${content.length} chars`,
1029
1164
  // src/tools/system.ts
1030
1165
  import { z as z5 } from "zod";
1031
1166
  import fs6 from "fs/promises";
1032
- import path7 from "path";
1167
+ import path8 from "path";
1033
1168
  function registerSystemTools(server2, vaultPath2) {
1034
1169
  server2.tool(
1035
1170
  "vault_list_sections",
@@ -1049,7 +1184,7 @@ function registerSystemTools(server2, vaultPath2) {
1049
1184
  };
1050
1185
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1051
1186
  }
1052
- const fullPath = path7.join(vaultPath2, notePath);
1187
+ const fullPath = path8.join(vaultPath2, notePath);
1053
1188
  try {
1054
1189
  await fs6.access(fullPath);
1055
1190
  } catch {
@@ -1157,18 +1292,18 @@ Message: ${undoResult.undoneCommit.message}` : void 0
1157
1292
 
1158
1293
  // src/core/vaultRoot.ts
1159
1294
  import * as fs7 from "fs";
1160
- import * as path8 from "path";
1295
+ import * as path9 from "path";
1161
1296
  var VAULT_MARKERS = [".obsidian", ".claude"];
1162
1297
  function findVaultRoot(startPath) {
1163
- let current = path8.resolve(startPath || process.cwd());
1298
+ let current = path9.resolve(startPath || process.cwd());
1164
1299
  while (true) {
1165
1300
  for (const marker of VAULT_MARKERS) {
1166
- const markerPath = path8.join(current, marker);
1301
+ const markerPath = path9.join(current, marker);
1167
1302
  if (fs7.existsSync(markerPath) && fs7.statSync(markerPath).isDirectory()) {
1168
1303
  return current;
1169
1304
  }
1170
1305
  }
1171
- const parent = path8.dirname(current);
1306
+ const parent = path9.dirname(current);
1172
1307
  if (parent === current) {
1173
1308
  return startPath || process.cwd();
1174
1309
  }
@@ -1179,11 +1314,14 @@ function findVaultRoot(startPath) {
1179
1314
  // src/index.ts
1180
1315
  var server = new McpServer({
1181
1316
  name: "flywheel-crank",
1182
- version: "0.1.0"
1317
+ version: "0.3.0"
1183
1318
  });
1184
1319
  var vaultPath = process.env.PROJECT_PATH || findVaultRoot();
1185
1320
  console.error(`[Crank] Starting Flywheel Crank MCP server`);
1186
1321
  console.error(`[Crank] Vault path: ${vaultPath}`);
1322
+ initializeEntityIndex(vaultPath).catch((err) => {
1323
+ console.error(`[Crank] Entity index initialization failed: ${err}`);
1324
+ });
1187
1325
  registerMutationTools(server, vaultPath);
1188
1326
  registerTaskTools(server, vaultPath);
1189
1327
  registerFrontmatterTools(server, vaultPath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-crank",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Deterministic vault mutations for Obsidian via MCP",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -36,6 +36,7 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@modelcontextprotocol/sdk": "^1.25.1",
39
+ "@velvetmonkey/vault-core": "^0.1.0",
39
40
  "gray-matter": "^4.0.3",
40
41
  "zod": "^3.22.4",
41
42
  "simple-git": "^3.22.0"