@velvetmonkey/flywheel-crank 0.2.1 → 0.3.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 +169 -36
  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,24 @@ 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 = section.endLine + 1;
123
+ if (section.contentStartLine <= section.endLine) {
124
+ insertLine = section.endLine + 1;
125
+ } else {
126
+ insertLine = section.contentStartLine;
127
+ }
128
+ lines.splice(insertLine, 0, formattedContent);
101
129
  }
102
- lines.splice(insertLine, 0, formattedContent);
103
130
  }
104
131
  return lines.join("\n");
105
132
  }
@@ -305,9 +332,104 @@ async function undoLastCommit(vaultPath2) {
305
332
  }
306
333
  }
307
334
 
335
+ // src/core/wikilinks.ts
336
+ import {
337
+ scanVaultEntities,
338
+ getAllEntities,
339
+ applyWikilinks,
340
+ loadEntityCache,
341
+ saveEntityCache
342
+ } from "@velvetmonkey/vault-core";
343
+ import path3 from "path";
344
+ var entityIndex = null;
345
+ var indexReady = false;
346
+ var indexError = null;
347
+ var DEFAULT_EXCLUDE_FOLDERS = [
348
+ "daily-notes",
349
+ "daily",
350
+ "weekly",
351
+ "monthly",
352
+ "quarterly",
353
+ "periodic",
354
+ "journal",
355
+ "inbox",
356
+ "templates"
357
+ ];
358
+ async function initializeEntityIndex(vaultPath2) {
359
+ const cacheFile = path3.join(vaultPath2, ".claude", "wikilink-entities.json");
360
+ try {
361
+ const cached = await loadEntityCache(cacheFile);
362
+ if (cached) {
363
+ entityIndex = cached;
364
+ indexReady = true;
365
+ console.error(`[Crank] Loaded ${cached._metadata.total_entities} entities from cache`);
366
+ const cacheAge = Date.now() - new Date(cached._metadata.generated_at).getTime();
367
+ if (cacheAge > 60 * 60 * 1e3) {
368
+ rebuildIndexInBackground(vaultPath2, cacheFile);
369
+ }
370
+ return;
371
+ }
372
+ await rebuildIndex(vaultPath2, cacheFile);
373
+ } catch (error) {
374
+ indexError = error instanceof Error ? error : new Error(String(error));
375
+ console.error(`[Crank] Failed to initialize entity index: ${indexError.message}`);
376
+ }
377
+ }
378
+ async function rebuildIndex(vaultPath2, cacheFile) {
379
+ console.error(`[Crank] Scanning vault for entities...`);
380
+ const startTime = Date.now();
381
+ entityIndex = await scanVaultEntities(vaultPath2, {
382
+ excludeFolders: DEFAULT_EXCLUDE_FOLDERS
383
+ });
384
+ indexReady = true;
385
+ const duration = Date.now() - startTime;
386
+ console.error(`[Crank] Entity index built: ${entityIndex._metadata.total_entities} entities in ${duration}ms`);
387
+ try {
388
+ await saveEntityCache(cacheFile, entityIndex);
389
+ console.error(`[Crank] Entity cache saved`);
390
+ } catch (e) {
391
+ console.error(`[Crank] Failed to save entity cache: ${e}`);
392
+ }
393
+ }
394
+ function rebuildIndexInBackground(vaultPath2, cacheFile) {
395
+ rebuildIndex(vaultPath2, cacheFile).catch((error) => {
396
+ console.error(`[Crank] Background index rebuild failed: ${error}`);
397
+ });
398
+ }
399
+ function isEntityIndexReady() {
400
+ return indexReady && entityIndex !== null;
401
+ }
402
+ function processWikilinks(content) {
403
+ if (!isEntityIndexReady() || !entityIndex) {
404
+ return {
405
+ content,
406
+ linksAdded: 0,
407
+ linkedEntities: []
408
+ };
409
+ }
410
+ const entities = getAllEntities(entityIndex);
411
+ return applyWikilinks(content, entities, {
412
+ firstOccurrenceOnly: true,
413
+ caseInsensitive: true
414
+ });
415
+ }
416
+ function maybeApplyWikilinks(content, skipWikilinks) {
417
+ if (skipWikilinks) {
418
+ return { content };
419
+ }
420
+ const result = processWikilinks(content);
421
+ if (result.linksAdded > 0) {
422
+ return {
423
+ content: result.content,
424
+ wikilinkInfo: `Applied ${result.linksAdded} wikilink(s): ${result.linkedEntities.join(", ")}`
425
+ };
426
+ }
427
+ return { content: result.content };
428
+ }
429
+
308
430
  // src/tools/mutations.ts
309
431
  import fs2 from "fs/promises";
310
- import path3 from "path";
432
+ import path4 from "path";
311
433
  function registerMutationTools(server2, vaultPath2) {
312
434
  server2.tool(
313
435
  "vault_add_to_section",
@@ -318,11 +440,12 @@ function registerMutationTools(server2, vaultPath2) {
318
440
  content: z.string().describe("Content to add to the section"),
319
441
  position: z.enum(["append", "prepend"]).default("append").describe("Where to insert content"),
320
442
  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)")
443
+ 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)")
322
445
  },
323
- async ({ path: notePath, section, content, position, format, commit }) => {
446
+ async ({ path: notePath, section, content, position, format, commit, skipWikilinks }) => {
324
447
  try {
325
- const fullPath = path3.join(vaultPath2, notePath);
448
+ const fullPath = path4.join(vaultPath2, notePath);
326
449
  try {
327
450
  await fs2.access(fullPath);
328
451
  } catch {
@@ -343,7 +466,8 @@ function registerMutationTools(server2, vaultPath2) {
343
466
  };
344
467
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
345
468
  }
346
- const formattedContent = formatContent(content, format);
469
+ const { content: wikilinkedContent, wikilinkInfo } = maybeApplyWikilinks(content, skipWikilinks);
470
+ const formattedContent = formatContent(wikilinkedContent, format);
347
471
  const updatedContent = insertInSection(
348
472
  fileContent,
349
473
  sectionBoundary,
@@ -361,7 +485,8 @@ function registerMutationTools(server2, vaultPath2) {
361
485
  gitError = gitResult.error;
362
486
  }
363
487
  }
364
- const preview = formattedContent;
488
+ const preview = formattedContent + (wikilinkInfo ? `
489
+ (${wikilinkInfo})` : "");
365
490
  const result = {
366
491
  success: true,
367
492
  message: `Added content to section "${sectionBoundary.name}" in ${notePath}`,
@@ -394,7 +519,7 @@ function registerMutationTools(server2, vaultPath2) {
394
519
  },
395
520
  async ({ path: notePath, section, pattern, mode, useRegex, commit }) => {
396
521
  try {
397
- const fullPath = path3.join(vaultPath2, notePath);
522
+ const fullPath = path4.join(vaultPath2, notePath);
398
523
  try {
399
524
  await fs2.access(fullPath);
400
525
  } catch {
@@ -470,11 +595,12 @@ function registerMutationTools(server2, vaultPath2) {
470
595
  replacement: z.string().describe("Text to replace with"),
471
596
  mode: z.enum(["first", "last", "all"]).default("first").describe("Which matches to replace"),
472
597
  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)")
598
+ 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")
474
600
  },
475
- async ({ path: notePath, section, search, replacement, mode, useRegex, commit }) => {
601
+ async ({ path: notePath, section, search, replacement, mode, useRegex, commit, skipWikilinks }) => {
476
602
  try {
477
- const fullPath = path3.join(vaultPath2, notePath);
603
+ const fullPath = path4.join(vaultPath2, notePath);
478
604
  try {
479
605
  await fs2.access(fullPath);
480
606
  } catch {
@@ -495,11 +621,12 @@ function registerMutationTools(server2, vaultPath2) {
495
621
  };
496
622
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
497
623
  }
624
+ const { content: wikilinkedReplacement, wikilinkInfo } = maybeApplyWikilinks(replacement, skipWikilinks);
498
625
  const replaceResult = replaceInSection(
499
626
  fileContent,
500
627
  sectionBoundary,
501
628
  search,
502
- replacement,
629
+ wikilinkedReplacement,
503
630
  mode,
504
631
  useRegex
505
632
  );
@@ -551,7 +678,7 @@ function registerMutationTools(server2, vaultPath2) {
551
678
  // src/tools/tasks.ts
552
679
  import { z as z2 } from "zod";
553
680
  import fs3 from "fs/promises";
554
- import path4 from "path";
681
+ import path5 from "path";
555
682
  var TASK_REGEX = /^(\s*)-\s*\[([ xX])\]\s*(.*)$/;
556
683
  function findTasks(content, section) {
557
684
  const lines = content.split("\n");
@@ -607,7 +734,7 @@ function registerTaskTools(server2, vaultPath2) {
607
734
  },
608
735
  async ({ path: notePath, task, section, commit }) => {
609
736
  try {
610
- const fullPath = path4.join(vaultPath2, notePath);
737
+ const fullPath = path5.join(vaultPath2, notePath);
611
738
  try {
612
739
  await fs3.access(fullPath);
613
740
  } catch {
@@ -695,11 +822,12 @@ function registerTaskTools(server2, vaultPath2) {
695
822
  task: z2.string().describe("Task text (without checkbox)"),
696
823
  position: z2.enum(["append", "prepend"]).default("append").describe("Where to add the task"),
697
824
  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)")
825
+ 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)")
699
827
  },
700
- async ({ path: notePath, section, task, position, completed, commit }) => {
828
+ async ({ path: notePath, section, task, position, completed, commit, skipWikilinks }) => {
701
829
  try {
702
- const fullPath = path4.join(vaultPath2, notePath);
830
+ const fullPath = path5.join(vaultPath2, notePath);
703
831
  try {
704
832
  await fs3.access(fullPath);
705
833
  } catch {
@@ -720,8 +848,9 @@ function registerTaskTools(server2, vaultPath2) {
720
848
  };
721
849
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
722
850
  }
851
+ const { content: wikilinkedTask, wikilinkInfo } = maybeApplyWikilinks(task.trim(), skipWikilinks);
723
852
  const checkbox = completed ? "[x]" : "[ ]";
724
- const taskLine = `- ${checkbox} ${task.trim()}`;
853
+ const taskLine = `- ${checkbox} ${wikilinkedTask}`;
725
854
  const updatedContent = insertInSection(
726
855
  fileContent,
727
856
  sectionBoundary,
@@ -743,7 +872,8 @@ function registerTaskTools(server2, vaultPath2) {
743
872
  success: true,
744
873
  message: `Added task to section "${sectionBoundary.name}" in ${notePath}`,
745
874
  path: notePath,
746
- preview: taskLine,
875
+ preview: taskLine + (wikilinkInfo ? `
876
+ (${wikilinkInfo})` : ""),
747
877
  gitCommit,
748
878
  gitError
749
879
  };
@@ -764,7 +894,7 @@ function registerTaskTools(server2, vaultPath2) {
764
894
  // src/tools/frontmatter.ts
765
895
  import { z as z3 } from "zod";
766
896
  import fs4 from "fs/promises";
767
- import path5 from "path";
897
+ import path6 from "path";
768
898
  function registerFrontmatterTools(server2, vaultPath2) {
769
899
  server2.tool(
770
900
  "vault_update_frontmatter",
@@ -776,7 +906,7 @@ function registerFrontmatterTools(server2, vaultPath2) {
776
906
  },
777
907
  async ({ path: notePath, frontmatter: updates, commit }) => {
778
908
  try {
779
- const fullPath = path5.join(vaultPath2, notePath);
909
+ const fullPath = path6.join(vaultPath2, notePath);
780
910
  try {
781
911
  await fs4.access(fullPath);
782
912
  } catch {
@@ -832,7 +962,7 @@ function registerFrontmatterTools(server2, vaultPath2) {
832
962
  },
833
963
  async ({ path: notePath, key, value, commit }) => {
834
964
  try {
835
- const fullPath = path5.join(vaultPath2, notePath);
965
+ const fullPath = path6.join(vaultPath2, notePath);
836
966
  try {
837
967
  await fs4.access(fullPath);
838
968
  } catch {
@@ -889,7 +1019,7 @@ function registerFrontmatterTools(server2, vaultPath2) {
889
1019
  // src/tools/notes.ts
890
1020
  import { z as z4 } from "zod";
891
1021
  import fs5 from "fs/promises";
892
- import path6 from "path";
1022
+ import path7 from "path";
893
1023
  function registerNoteTools(server2, vaultPath2) {
894
1024
  server2.tool(
895
1025
  "vault_create_note",
@@ -911,7 +1041,7 @@ function registerNoteTools(server2, vaultPath2) {
911
1041
  };
912
1042
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
913
1043
  }
914
- const fullPath = path6.join(vaultPath2, notePath);
1044
+ const fullPath = path7.join(vaultPath2, notePath);
915
1045
  try {
916
1046
  await fs5.access(fullPath);
917
1047
  if (!overwrite) {
@@ -924,7 +1054,7 @@ function registerNoteTools(server2, vaultPath2) {
924
1054
  }
925
1055
  } catch {
926
1056
  }
927
- const dir = path6.dirname(fullPath);
1057
+ const dir = path7.dirname(fullPath);
928
1058
  await fs5.mkdir(dir, { recursive: true });
929
1059
  await writeVaultFile(vaultPath2, notePath, content, frontmatter);
930
1060
  let gitCommit;
@@ -983,7 +1113,7 @@ Content length: ${content.length} chars`,
983
1113
  };
984
1114
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
985
1115
  }
986
- const fullPath = path6.join(vaultPath2, notePath);
1116
+ const fullPath = path7.join(vaultPath2, notePath);
987
1117
  try {
988
1118
  await fs5.access(fullPath);
989
1119
  } catch {
@@ -1029,7 +1159,7 @@ Content length: ${content.length} chars`,
1029
1159
  // src/tools/system.ts
1030
1160
  import { z as z5 } from "zod";
1031
1161
  import fs6 from "fs/promises";
1032
- import path7 from "path";
1162
+ import path8 from "path";
1033
1163
  function registerSystemTools(server2, vaultPath2) {
1034
1164
  server2.tool(
1035
1165
  "vault_list_sections",
@@ -1049,7 +1179,7 @@ function registerSystemTools(server2, vaultPath2) {
1049
1179
  };
1050
1180
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1051
1181
  }
1052
- const fullPath = path7.join(vaultPath2, notePath);
1182
+ const fullPath = path8.join(vaultPath2, notePath);
1053
1183
  try {
1054
1184
  await fs6.access(fullPath);
1055
1185
  } catch {
@@ -1157,18 +1287,18 @@ Message: ${undoResult.undoneCommit.message}` : void 0
1157
1287
 
1158
1288
  // src/core/vaultRoot.ts
1159
1289
  import * as fs7 from "fs";
1160
- import * as path8 from "path";
1290
+ import * as path9 from "path";
1161
1291
  var VAULT_MARKERS = [".obsidian", ".claude"];
1162
1292
  function findVaultRoot(startPath) {
1163
- let current = path8.resolve(startPath || process.cwd());
1293
+ let current = path9.resolve(startPath || process.cwd());
1164
1294
  while (true) {
1165
1295
  for (const marker of VAULT_MARKERS) {
1166
- const markerPath = path8.join(current, marker);
1296
+ const markerPath = path9.join(current, marker);
1167
1297
  if (fs7.existsSync(markerPath) && fs7.statSync(markerPath).isDirectory()) {
1168
1298
  return current;
1169
1299
  }
1170
1300
  }
1171
- const parent = path8.dirname(current);
1301
+ const parent = path9.dirname(current);
1172
1302
  if (parent === current) {
1173
1303
  return startPath || process.cwd();
1174
1304
  }
@@ -1184,6 +1314,9 @@ var server = new McpServer({
1184
1314
  var vaultPath = process.env.PROJECT_PATH || findVaultRoot();
1185
1315
  console.error(`[Crank] Starting Flywheel Crank MCP server`);
1186
1316
  console.error(`[Crank] Vault path: ${vaultPath}`);
1317
+ initializeEntityIndex(vaultPath).catch((err) => {
1318
+ console.error(`[Crank] Entity index initialization failed: ${err}`);
1319
+ });
1187
1320
  registerMutationTools(server, vaultPath);
1188
1321
  registerTaskTools(server, vaultPath);
1189
1322
  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.0",
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"