@velvetmonkey/flywheel-memory 2.0.58 → 2.0.60

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 +264 -134
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -13184,7 +13184,7 @@ function ensureSectionExists(content, section, notePath) {
13184
13184
  return { error: errorResult(notePath, message) };
13185
13185
  }
13186
13186
  async function withVaultFile(options, operation) {
13187
- const { vaultPath: vaultPath2, notePath, commit, commitPrefix, section, actionDescription, scoping } = options;
13187
+ const { vaultPath: vaultPath2, notePath, commit, commitPrefix, section, actionDescription, scoping, dryRun } = options;
13188
13188
  try {
13189
13189
  const existsError = await ensureFileExists(vaultPath2, notePath);
13190
13190
  if (existsError) {
@@ -13219,6 +13219,17 @@ async function withVaultFile(options, operation) {
13219
13219
  if ("error" in result) {
13220
13220
  return formatMcpResult(result.error);
13221
13221
  }
13222
+ const { opResult, frontmatter, lineEnding } = result;
13223
+ if (dryRun) {
13224
+ const dryResult = successResult(notePath, `[dry run] ${opResult.message}`, {}, {
13225
+ preview: opResult.preview,
13226
+ warnings: opResult.warnings,
13227
+ outputIssues: opResult.outputIssues,
13228
+ normalizationChanges: opResult.normalizationChanges,
13229
+ dryRun: true
13230
+ });
13231
+ return formatMcpResult(dryResult);
13232
+ }
13222
13233
  const fullPath = path21.join(vaultPath2, notePath);
13223
13234
  const statBefore = await fs19.stat(fullPath);
13224
13235
  if (statBefore.mtimeMs !== result.mtimeMs) {
@@ -13228,7 +13239,6 @@ async function withVaultFile(options, operation) {
13228
13239
  return formatMcpResult(result.error);
13229
13240
  }
13230
13241
  }
13231
- const { opResult, frontmatter, lineEnding } = result;
13232
13242
  let finalFrontmatter = opResult.updatedFrontmatter ?? frontmatter;
13233
13243
  if (scoping && (scoping.agent_id || scoping.session_id)) {
13234
13244
  finalFrontmatter = injectMutationMetadata(finalFrontmatter, scoping);
@@ -13256,7 +13266,7 @@ async function withVaultFile(options, operation) {
13256
13266
  }
13257
13267
  }
13258
13268
  async function withVaultFrontmatter(options, operation) {
13259
- const { vaultPath: vaultPath2, notePath, commit, commitPrefix, actionDescription } = options;
13269
+ const { vaultPath: vaultPath2, notePath, commit, commitPrefix, actionDescription, dryRun } = options;
13260
13270
  try {
13261
13271
  const existsError = await ensureFileExists(vaultPath2, notePath);
13262
13272
  if (existsError) {
@@ -13265,6 +13275,13 @@ async function withVaultFrontmatter(options, operation) {
13265
13275
  const { content, frontmatter, lineEnding } = await readVaultFile(vaultPath2, notePath);
13266
13276
  const ctx = { content, frontmatter, lineEnding, vaultPath: vaultPath2, notePath };
13267
13277
  const opResult = await operation(ctx);
13278
+ if (dryRun) {
13279
+ const result2 = successResult(notePath, `[dry run] ${opResult.message}`, {}, {
13280
+ preview: opResult.preview,
13281
+ dryRun: true
13282
+ });
13283
+ return formatMcpResult(result2);
13284
+ }
13268
13285
  await writeVaultFile(vaultPath2, notePath, content, opResult.updatedFrontmatter, lineEnding);
13269
13286
  const gitInfo = await handleGitCommit(vaultPath2, notePath, commit, commitPrefix);
13270
13287
  const result = successResult(notePath, opResult.message, gitInfo, {
@@ -13360,10 +13377,11 @@ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", con
13360
13377
  normalize: z11.boolean().default(true).describe("Auto-fix common issues before formatting (replace \u2022 with -, trim excessive whitespace, etc.)"),
13361
13378
  guardrails: z11.enum(["warn", "strict", "off"]).default("warn").describe('Output validation mode: "warn" returns issues but proceeds, "strict" blocks on errors, "off" disables'),
13362
13379
  linkedEntities: z11.array(z11.string()).optional().describe("Entity names already linked in the content. When skipWikilinks=true, these are tracked for feedback without re-processing the content."),
13380
+ dry_run: z11.boolean().optional().default(false).describe("Preview changes without writing to disk"),
13363
13381
  agent_id: z11.string().optional().describe('Agent identifier for multi-agent scoping (e.g., "claude-opus", "planning-agent")'),
13364
13382
  session_id: z11.string().optional().describe('Session identifier for conversation scoping (e.g., "sess-abc123")')
13365
13383
  },
13366
- async ({ path: notePath, section, content, create_if_missing, position, format, commit, skipWikilinks, preserveListNesting, bumpHeadings, suggestOutgoingLinks, maxSuggestions, validate, normalize, guardrails, linkedEntities, agent_id, session_id }) => {
13384
+ async ({ path: notePath, section, content, create_if_missing, position, format, commit, skipWikilinks, preserveListNesting, bumpHeadings, suggestOutgoingLinks, maxSuggestions, validate, normalize, guardrails, linkedEntities, dry_run, agent_id, session_id }) => {
13367
13385
  let noteCreated = false;
13368
13386
  let templateUsed;
13369
13387
  if (create_if_missing) {
@@ -13385,7 +13403,8 @@ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", con
13385
13403
  commitPrefix: "[Flywheel:Add]",
13386
13404
  section,
13387
13405
  actionDescription: "add content",
13388
- scoping: agent_id || session_id ? { agent_id, session_id } : void 0
13406
+ scoping: agent_id || session_id ? { agent_id, session_id } : void 0,
13407
+ dryRun: dry_run
13389
13408
  },
13390
13409
  async (ctx) => {
13391
13410
  const validationResult = runValidationPipeline(content, format, {
@@ -13454,10 +13473,11 @@ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", con
13454
13473
  mode: z11.enum(["first", "last", "all"]).default("first").describe("Which matches to remove"),
13455
13474
  useRegex: z11.boolean().default(false).describe("Treat pattern as regex"),
13456
13475
  commit: z11.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
13476
+ dry_run: z11.boolean().optional().default(false).describe("Preview changes without writing to disk"),
13457
13477
  agent_id: z11.string().optional().describe("Agent identifier for multi-agent scoping"),
13458
13478
  session_id: z11.string().optional().describe("Session identifier for conversation scoping")
13459
13479
  },
13460
- async ({ path: notePath, section, pattern, mode, useRegex, commit, agent_id, session_id }) => {
13480
+ async ({ path: notePath, section, pattern, mode, useRegex, commit, dry_run, agent_id, session_id }) => {
13461
13481
  return withVaultFile(
13462
13482
  {
13463
13483
  vaultPath: vaultPath2,
@@ -13466,7 +13486,8 @@ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", con
13466
13486
  commitPrefix: "[Flywheel:Remove]",
13467
13487
  section,
13468
13488
  actionDescription: "remove content",
13469
- scoping: agent_id || session_id ? { agent_id, session_id } : void 0
13489
+ scoping: agent_id || session_id ? { agent_id, session_id } : void 0,
13490
+ dryRun: dry_run
13470
13491
  },
13471
13492
  async (ctx) => {
13472
13493
  const removeResult = removeFromSection(
@@ -13505,10 +13526,11 @@ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", con
13505
13526
  validate: z11.boolean().default(true).describe("Check input for common issues (double timestamps, non-markdown bullets, etc.)"),
13506
13527
  normalize: z11.boolean().default(true).describe("Auto-fix common issues before formatting (replace \u2022 with -, trim excessive whitespace, etc.)"),
13507
13528
  guardrails: z11.enum(["warn", "strict", "off"]).default("warn").describe('Output validation mode: "warn" returns issues but proceeds, "strict" blocks on errors, "off" disables'),
13529
+ dry_run: z11.boolean().optional().default(false).describe("Preview changes without writing to disk"),
13508
13530
  agent_id: z11.string().optional().describe("Agent identifier for multi-agent scoping"),
13509
13531
  session_id: z11.string().optional().describe("Session identifier for conversation scoping")
13510
13532
  },
13511
- async ({ path: notePath, section, search, replacement, mode, useRegex, commit, skipWikilinks, suggestOutgoingLinks, maxSuggestions, validate, normalize, guardrails, agent_id, session_id }) => {
13533
+ async ({ path: notePath, section, search, replacement, mode, useRegex, commit, skipWikilinks, suggestOutgoingLinks, maxSuggestions, validate, normalize, guardrails, dry_run, agent_id, session_id }) => {
13512
13534
  return withVaultFile(
13513
13535
  {
13514
13536
  vaultPath: vaultPath2,
@@ -13517,7 +13539,8 @@ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", con
13517
13539
  commitPrefix: "[Flywheel:Replace]",
13518
13540
  section,
13519
13541
  actionDescription: "replace content",
13520
- scoping: agent_id || session_id ? { agent_id, session_id } : void 0
13542
+ scoping: agent_id || session_id ? { agent_id, session_id } : void 0,
13543
+ dryRun: dry_run
13521
13544
  },
13522
13545
  async (ctx) => {
13523
13546
  const validationResult = runValidationPipeline(replacement, "plain", {
@@ -13633,10 +13656,11 @@ function registerTaskTools(server2, vaultPath2) {
13633
13656
  task: z12.string().describe("Task text to find (partial match supported)"),
13634
13657
  section: z12.string().optional().describe("Optional: limit search to this section"),
13635
13658
  commit: z12.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
13659
+ dry_run: z12.boolean().optional().default(false).describe("Preview changes without writing to disk"),
13636
13660
  agent_id: z12.string().optional().describe("Agent identifier for multi-agent scoping"),
13637
13661
  session_id: z12.string().optional().describe("Session identifier for conversation scoping")
13638
13662
  },
13639
- async ({ path: notePath, task, section, commit, agent_id, session_id }) => {
13663
+ async ({ path: notePath, task, section, commit, dry_run, agent_id, session_id }) => {
13640
13664
  try {
13641
13665
  const existsError = await ensureFileExists(vaultPath2, notePath);
13642
13666
  if (existsError) {
@@ -13665,6 +13689,16 @@ function registerTaskTools(server2, vaultPath2) {
13665
13689
  if (!toggleResult) {
13666
13690
  return formatMcpResult(errorResult(notePath, "Failed to toggle task"));
13667
13691
  }
13692
+ const newStatus = toggleResult.newState ? "completed" : "incomplete";
13693
+ const checkbox = toggleResult.newState ? "[x]" : "[ ]";
13694
+ if (dry_run) {
13695
+ return formatMcpResult(
13696
+ successResult(notePath, `[dry run] Toggled task to ${newStatus} in ${notePath}`, {}, {
13697
+ preview: `${checkbox} ${matchingTask.text}`,
13698
+ dryRun: true
13699
+ })
13700
+ );
13701
+ }
13668
13702
  let finalFrontmatter = frontmatter;
13669
13703
  if (agent_id || session_id) {
13670
13704
  finalFrontmatter = injectMutationMetadata(frontmatter, { agent_id, session_id });
@@ -13673,8 +13707,6 @@ function registerTaskTools(server2, vaultPath2) {
13673
13707
  await updateTaskCacheForFile(vaultPath2, notePath).catch(() => {
13674
13708
  });
13675
13709
  const gitInfo = await handleGitCommit(vaultPath2, notePath, commit, "[Flywheel:Task]");
13676
- const newStatus = toggleResult.newState ? "completed" : "incomplete";
13677
- const checkbox = toggleResult.newState ? "[x]" : "[ ]";
13678
13710
  return formatMcpResult(
13679
13711
  successResult(notePath, `Toggled task to ${newStatus} in ${notePath}`, gitInfo, {
13680
13712
  preview: `${checkbox} ${matchingTask.text}`
@@ -13704,10 +13736,11 @@ function registerTaskTools(server2, vaultPath2) {
13704
13736
  validate: z12.boolean().default(true).describe("Check input for common issues"),
13705
13737
  normalize: z12.boolean().default(true).describe("Auto-fix common issues before formatting"),
13706
13738
  guardrails: z12.enum(["warn", "strict", "off"]).default("warn").describe("Output validation mode"),
13739
+ dry_run: z12.boolean().optional().default(false).describe("Preview changes without writing to disk"),
13707
13740
  agent_id: z12.string().optional().describe("Agent identifier for multi-agent scoping"),
13708
13741
  session_id: z12.string().optional().describe("Session identifier for conversation scoping")
13709
13742
  },
13710
- async ({ path: notePath, section, task, position, completed, commit, skipWikilinks, suggestOutgoingLinks, maxSuggestions, preserveListNesting, validate, normalize, guardrails, agent_id, session_id }) => {
13743
+ async ({ path: notePath, section, task, position, completed, commit, skipWikilinks, suggestOutgoingLinks, maxSuggestions, preserveListNesting, validate, normalize, guardrails, dry_run, agent_id, session_id }) => {
13711
13744
  return withVaultFile(
13712
13745
  {
13713
13746
  vaultPath: vaultPath2,
@@ -13716,7 +13749,8 @@ function registerTaskTools(server2, vaultPath2) {
13716
13749
  commitPrefix: "[Flywheel:Task]",
13717
13750
  section,
13718
13751
  actionDescription: "add task",
13719
- scoping: agent_id || session_id ? { agent_id, session_id } : void 0
13752
+ scoping: agent_id || session_id ? { agent_id, session_id } : void 0,
13753
+ dryRun: dry_run
13720
13754
  },
13721
13755
  async (ctx) => {
13722
13756
  const validationResult = runValidationPipeline(task.trim(), "task", {
@@ -13774,16 +13808,18 @@ Example: vault_update_frontmatter({ path: "projects/alpha.md", frontmatter: { st
13774
13808
  path: z13.string().describe("Vault-relative path to the note"),
13775
13809
  frontmatter: z13.record(z13.any()).describe("Frontmatter fields to update (JSON object)"),
13776
13810
  only_if_missing: z13.boolean().default(false).describe("If true, only add fields that don't already exist in the frontmatter (skip existing keys)"),
13777
- commit: z13.boolean().default(false).describe("If true, commit this change to git (creates undo point)")
13811
+ commit: z13.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
13812
+ dry_run: z13.boolean().optional().default(false).describe("Preview changes without writing to disk")
13778
13813
  },
13779
- async ({ path: notePath, frontmatter: updates, only_if_missing, commit }) => {
13814
+ async ({ path: notePath, frontmatter: updates, only_if_missing, commit, dry_run }) => {
13780
13815
  return withVaultFrontmatter(
13781
13816
  {
13782
13817
  vaultPath: vaultPath2,
13783
13818
  notePath,
13784
13819
  commit,
13785
13820
  commitPrefix: "[Flywheel:FM]",
13786
- actionDescription: "update frontmatter"
13821
+ actionDescription: "update frontmatter",
13822
+ dryRun: dry_run
13787
13823
  },
13788
13824
  async (ctx) => {
13789
13825
  let effectiveUpdates;
@@ -13841,10 +13877,11 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
13841
13877
  skipWikilinks: z14.boolean().default(false).describe("If true, skip auto-wikilink application (wikilinks are applied by default)"),
13842
13878
  suggestOutgoingLinks: z14.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]], [[Philosophy]]").'),
13843
13879
  maxSuggestions: z14.number().min(1).max(10).default(5).describe("Maximum number of suggested wikilinks to append (1-10, default: 5)"),
13880
+ dry_run: z14.boolean().optional().default(false).describe("Preview changes without writing to disk"),
13844
13881
  agent_id: z14.string().optional().describe("Agent identifier for multi-agent scoping"),
13845
13882
  session_id: z14.string().optional().describe("Session identifier for conversation scoping")
13846
13883
  },
13847
- async ({ path: notePath, content, template, frontmatter, overwrite, commit, skipWikilinks, suggestOutgoingLinks, maxSuggestions, agent_id, session_id }) => {
13884
+ async ({ path: notePath, content, template, frontmatter, overwrite, commit, skipWikilinks, suggestOutgoingLinks, maxSuggestions, dry_run, agent_id, session_id }) => {
13848
13885
  try {
13849
13886
  if (!validatePath(vaultPath2, notePath)) {
13850
13887
  return formatMcpResult(errorResult(notePath, "Invalid path: path traversal not allowed"));
@@ -13922,8 +13959,6 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
13922
13959
  if (agent_id || session_id) {
13923
13960
  finalFrontmatter = injectMutationMetadata(effectiveFrontmatter, { agent_id, session_id });
13924
13961
  }
13925
- await writeVaultFile(vaultPath2, notePath, processedContent, finalFrontmatter);
13926
- const gitInfo = await handleGitCommit(vaultPath2, notePath, commit, "[Flywheel:Create]");
13927
13962
  const infoLines = [wikilinkInfo, suggestInfo].filter(Boolean);
13928
13963
  const previewLines = [
13929
13964
  `Frontmatter fields: ${Object.keys(effectiveFrontmatter).join(", ") || "none"}`,
@@ -13946,6 +13981,17 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
13946
13981
  previewLines.push('Tip: Add aliases to frontmatter for flexible wikilink matching (e.g., aliases: ["Short Name"])');
13947
13982
  }
13948
13983
  }
13984
+ if (dry_run) {
13985
+ return formatMcpResult(
13986
+ successResult(notePath, `[dry run] Would create note: ${notePath}`, {}, {
13987
+ preview: previewLines.join("\n"),
13988
+ warnings: warnings.length > 0 ? warnings : void 0,
13989
+ dryRun: true
13990
+ })
13991
+ );
13992
+ }
13993
+ await writeVaultFile(vaultPath2, notePath, processedContent, finalFrontmatter);
13994
+ const gitInfo = await handleGitCommit(vaultPath2, notePath, commit, "[Flywheel:Create]");
13949
13995
  return formatMcpResult(
13950
13996
  successResult(notePath, `Created note: ${notePath}`, gitInfo, {
13951
13997
  preview: previewLines.join("\n"),
@@ -13965,9 +14011,10 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
13965
14011
  {
13966
14012
  path: z14.string().describe("Vault-relative path to the note to delete"),
13967
14013
  confirm: z14.boolean().default(false).describe("Must be true to confirm deletion"),
13968
- commit: z14.boolean().default(false).describe("If true, commit this change to git (creates undo point)")
14014
+ commit: z14.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
14015
+ dry_run: z14.boolean().optional().default(false).describe("Preview what would be deleted without actually deleting")
13969
14016
  },
13970
- async ({ path: notePath, confirm, commit }) => {
14017
+ async ({ path: notePath, confirm, commit, dry_run }) => {
13971
14018
  try {
13972
14019
  if (!validatePath(vaultPath2, notePath)) {
13973
14020
  return formatMcpResult(errorResult(notePath, "Invalid path: path traversal not allowed"));
@@ -13993,6 +14040,19 @@ ${sources}`;
13993
14040
  } catch {
13994
14041
  }
13995
14042
  }
14043
+ if (dry_run) {
14044
+ const previewLines = [`Would delete: ${notePath}`];
14045
+ if (backlinkWarning) {
14046
+ previewLines.push("");
14047
+ previewLines.push("Warning: " + backlinkWarning);
14048
+ }
14049
+ return formatMcpResult(
14050
+ successResult(notePath, `[dry run] Would delete note: ${notePath}`, {}, {
14051
+ preview: previewLines.join("\n"),
14052
+ dryRun: true
14053
+ })
14054
+ );
14055
+ }
13996
14056
  if (!confirm) {
13997
14057
  const previewLines = ["Deletion requires explicit confirmation (confirm=true)"];
13998
14058
  if (backlinkWarning) {
@@ -14127,9 +14187,10 @@ function registerMoveNoteTools(server2, vaultPath2) {
14127
14187
  oldPath: z15.string().describe('Vault-relative path to move from (e.g., "inbox/note.md")'),
14128
14188
  newPath: z15.string().describe('Vault-relative path to move to (e.g., "projects/note.md")'),
14129
14189
  updateBacklinks: z15.boolean().default(true).describe("If true (default), updates all backlinks pointing to this note"),
14130
- commit: z15.boolean().default(false).describe("If true, commit all changes to git")
14190
+ commit: z15.boolean().default(false).describe("If true, commit all changes to git"),
14191
+ dry_run: z15.boolean().optional().default(false).describe("Preview what would change without moving any files")
14131
14192
  },
14132
- async ({ oldPath, newPath, updateBacklinks, commit }) => {
14193
+ async ({ oldPath, newPath, updateBacklinks, commit, dry_run }) => {
14133
14194
  try {
14134
14195
  if (!validatePath(vaultPath2, oldPath)) {
14135
14196
  const result2 = {
@@ -14175,29 +14236,60 @@ function registerMoveNoteTools(server2, vaultPath2) {
14175
14236
  const oldTitle = getTitleFromPath(oldPath);
14176
14237
  const newTitle = getTitleFromPath(newPath);
14177
14238
  let backlinkCount = 0;
14178
- let updatedBacklinks = 0;
14179
14239
  const backlinkUpdates = [];
14180
14240
  if (updateBacklinks && oldTitle.toLowerCase() !== newTitle.toLowerCase()) {
14181
- const allOldTitles = [oldTitle, ...aliases];
14182
14241
  const backlinks = await findBacklinks(vaultPath2, oldTitle, aliases);
14183
14242
  backlinkCount = backlinks.reduce((sum, b) => sum + b.links.length, 0);
14184
- for (const backlink of backlinks) {
14185
- if (backlink.path === oldPath) continue;
14186
- const updateResult = await updateBacklinksInFile(
14187
- vaultPath2,
14188
- backlink.path,
14189
- allOldTitles,
14190
- newTitle
14191
- );
14192
- if (updateResult.updated) {
14193
- updatedBacklinks += updateResult.linksUpdated;
14243
+ if (dry_run) {
14244
+ for (const backlink of backlinks) {
14245
+ if (backlink.path === oldPath) continue;
14194
14246
  backlinkUpdates.push({
14195
14247
  path: backlink.path,
14196
- linksUpdated: updateResult.linksUpdated
14248
+ linksUpdated: backlink.links.length
14197
14249
  });
14198
14250
  }
14251
+ } else {
14252
+ const allOldTitles = [oldTitle, ...aliases];
14253
+ for (const backlink of backlinks) {
14254
+ if (backlink.path === oldPath) continue;
14255
+ const updateResult = await updateBacklinksInFile(
14256
+ vaultPath2,
14257
+ backlink.path,
14258
+ allOldTitles,
14259
+ newTitle
14260
+ );
14261
+ if (updateResult.updated) {
14262
+ backlinkUpdates.push({
14263
+ path: backlink.path,
14264
+ linksUpdated: updateResult.linksUpdated
14265
+ });
14266
+ }
14267
+ }
14199
14268
  }
14200
14269
  }
14270
+ const updatedBacklinks = backlinkUpdates.reduce((sum, b) => sum + b.linksUpdated, 0);
14271
+ const previewLines = [
14272
+ `${dry_run ? "Would move" : "Moved"}: ${oldPath} \u2192 ${newPath}`
14273
+ ];
14274
+ if (backlinkCount > 0) {
14275
+ previewLines.push(`Backlinks found: ${backlinkCount}`);
14276
+ previewLines.push(`Backlinks ${dry_run ? "to update" : "updated"}: ${updatedBacklinks}`);
14277
+ if (backlinkUpdates.length > 0) {
14278
+ previewLines.push(`Files ${dry_run ? "to modify" : "modified"}: ${backlinkUpdates.map((b) => b.path).join(", ")}`);
14279
+ }
14280
+ } else {
14281
+ previewLines.push("No backlinks found");
14282
+ }
14283
+ if (dry_run) {
14284
+ const result2 = {
14285
+ success: true,
14286
+ message: `[dry run] Would move note: ${oldPath} \u2192 ${newPath}`,
14287
+ path: newPath,
14288
+ preview: previewLines.join("\n"),
14289
+ dryRun: true
14290
+ };
14291
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
14292
+ }
14201
14293
  const destDir = path24.dirname(newFullPath);
14202
14294
  await fs22.mkdir(destDir, { recursive: true });
14203
14295
  await fs22.rename(oldFullPath, newFullPath);
@@ -14220,18 +14312,6 @@ function registerMoveNoteTools(server2, vaultPath2) {
14220
14312
  initializeEntityIndex(vaultPath2).catch((err) => {
14221
14313
  console.error(`[Flywheel] Entity cache rebuild failed: ${err}`);
14222
14314
  });
14223
- const previewLines = [
14224
- `Moved: ${oldPath} \u2192 ${newPath}`
14225
- ];
14226
- if (backlinkCount > 0) {
14227
- previewLines.push(`Backlinks found: ${backlinkCount}`);
14228
- previewLines.push(`Backlinks updated: ${updatedBacklinks}`);
14229
- if (backlinkUpdates.length > 0) {
14230
- previewLines.push(`Files modified: ${backlinkUpdates.map((b) => b.path).join(", ")}`);
14231
- }
14232
- } else {
14233
- previewLines.push("No backlinks found");
14234
- }
14235
14315
  const result = {
14236
14316
  success: true,
14237
14317
  message: `Moved note: ${oldPath} \u2192 ${newPath}`,
@@ -14260,9 +14340,10 @@ function registerMoveNoteTools(server2, vaultPath2) {
14260
14340
  path: z15.string().describe("Vault-relative path to the note to rename"),
14261
14341
  newTitle: z15.string().describe("New title for the note (without .md extension)"),
14262
14342
  updateBacklinks: z15.boolean().default(true).describe("If true (default), updates all backlinks pointing to this note"),
14263
- commit: z15.boolean().default(false).describe("If true, commit all changes to git")
14343
+ commit: z15.boolean().default(false).describe("If true, commit all changes to git"),
14344
+ dry_run: z15.boolean().optional().default(false).describe("Preview what would change without renaming any files")
14264
14345
  },
14265
- async ({ path: notePath, newTitle, updateBacklinks, commit }) => {
14346
+ async ({ path: notePath, newTitle, updateBacklinks, commit, dry_run }) => {
14266
14347
  try {
14267
14348
  if (!validatePath(vaultPath2, notePath)) {
14268
14349
  const result2 = {
@@ -14315,29 +14396,60 @@ function registerMoveNoteTools(server2, vaultPath2) {
14315
14396
  const aliases = extractAliases2(parsed.data);
14316
14397
  const oldTitle = getTitleFromPath(notePath);
14317
14398
  let backlinkCount = 0;
14318
- let updatedBacklinks = 0;
14319
14399
  const backlinkUpdates = [];
14320
14400
  if (updateBacklinks && oldTitle.toLowerCase() !== sanitizedTitle.toLowerCase()) {
14321
- const allOldTitles = [oldTitle, ...aliases];
14322
14401
  const backlinks = await findBacklinks(vaultPath2, oldTitle, aliases);
14323
14402
  backlinkCount = backlinks.reduce((sum, b) => sum + b.links.length, 0);
14324
- for (const backlink of backlinks) {
14325
- if (backlink.path === notePath) continue;
14326
- const updateResult = await updateBacklinksInFile(
14327
- vaultPath2,
14328
- backlink.path,
14329
- allOldTitles,
14330
- sanitizedTitle
14331
- );
14332
- if (updateResult.updated) {
14333
- updatedBacklinks += updateResult.linksUpdated;
14403
+ if (dry_run) {
14404
+ for (const backlink of backlinks) {
14405
+ if (backlink.path === notePath) continue;
14334
14406
  backlinkUpdates.push({
14335
14407
  path: backlink.path,
14336
- linksUpdated: updateResult.linksUpdated
14408
+ linksUpdated: backlink.links.length
14337
14409
  });
14338
14410
  }
14411
+ } else {
14412
+ const allOldTitles = [oldTitle, ...aliases];
14413
+ for (const backlink of backlinks) {
14414
+ if (backlink.path === notePath) continue;
14415
+ const updateResult = await updateBacklinksInFile(
14416
+ vaultPath2,
14417
+ backlink.path,
14418
+ allOldTitles,
14419
+ sanitizedTitle
14420
+ );
14421
+ if (updateResult.updated) {
14422
+ backlinkUpdates.push({
14423
+ path: backlink.path,
14424
+ linksUpdated: updateResult.linksUpdated
14425
+ });
14426
+ }
14427
+ }
14339
14428
  }
14340
14429
  }
14430
+ const updatedBacklinks = backlinkUpdates.reduce((sum, b) => sum + b.linksUpdated, 0);
14431
+ const previewLines = [
14432
+ `${dry_run ? "Would rename" : "Renamed"}: "${oldTitle}" \u2192 "${sanitizedTitle}"`
14433
+ ];
14434
+ if (backlinkCount > 0) {
14435
+ previewLines.push(`Backlinks found: ${backlinkCount}`);
14436
+ previewLines.push(`Backlinks ${dry_run ? "to update" : "updated"}: ${updatedBacklinks}`);
14437
+ if (backlinkUpdates.length > 0) {
14438
+ previewLines.push(`Files ${dry_run ? "to modify" : "modified"}: ${backlinkUpdates.map((b) => b.path).join(", ")}`);
14439
+ }
14440
+ } else {
14441
+ previewLines.push("No backlinks found");
14442
+ }
14443
+ if (dry_run) {
14444
+ const result2 = {
14445
+ success: true,
14446
+ message: `[dry run] Would rename note: ${oldTitle} \u2192 ${sanitizedTitle}`,
14447
+ path: newPath,
14448
+ preview: previewLines.join("\n"),
14449
+ dryRun: true
14450
+ };
14451
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
14452
+ }
14341
14453
  if (fullPath !== newFullPath) {
14342
14454
  await fs22.rename(fullPath, newFullPath);
14343
14455
  }
@@ -14360,18 +14472,6 @@ function registerMoveNoteTools(server2, vaultPath2) {
14360
14472
  initializeEntityIndex(vaultPath2).catch((err) => {
14361
14473
  console.error(`[Flywheel] Entity cache rebuild failed: ${err}`);
14362
14474
  });
14363
- const previewLines = [
14364
- `Renamed: "${oldTitle}" \u2192 "${sanitizedTitle}"`
14365
- ];
14366
- if (backlinkCount > 0) {
14367
- previewLines.push(`Backlinks found: ${backlinkCount}`);
14368
- previewLines.push(`Backlinks updated: ${updatedBacklinks}`);
14369
- if (backlinkUpdates.length > 0) {
14370
- previewLines.push(`Files modified: ${backlinkUpdates.map((b) => b.path).join(", ")}`);
14371
- }
14372
- } else {
14373
- previewLines.push("No backlinks found");
14374
- }
14375
14475
  const result = {
14376
14476
  success: true,
14377
14477
  message: `Renamed note: ${oldTitle} \u2192 ${sanitizedTitle}`,
@@ -14405,9 +14505,10 @@ function registerMergeTools(server2, vaultPath2) {
14405
14505
  "Merge a source entity note into a target entity note: adds alias, appends content, updates wikilinks, deletes source",
14406
14506
  {
14407
14507
  source_path: z16.string().describe("Vault-relative path of the note to merge FROM (will be deleted)"),
14408
- target_path: z16.string().describe("Vault-relative path of the note to merge INTO (receives alias + content)")
14508
+ target_path: z16.string().describe("Vault-relative path of the note to merge INTO (receives alias + content)"),
14509
+ dry_run: z16.boolean().optional().default(false).describe("Preview merge plan without modifying any files")
14409
14510
  },
14410
- async ({ source_path, target_path }) => {
14511
+ async ({ source_path, target_path, dry_run }) => {
14411
14512
  try {
14412
14513
  if (!validatePath(vaultPath2, source_path)) {
14413
14514
  const result2 = {
@@ -14478,18 +14579,46 @@ ${trimmedSource}`;
14478
14579
  const backlinks = await findBacklinks(vaultPath2, sourceTitle, sourceAliases);
14479
14580
  let totalBacklinksUpdated = 0;
14480
14581
  const modifiedFiles = [];
14481
- for (const backlink of backlinks) {
14482
- if (backlink.path === source_path || backlink.path === target_path) continue;
14483
- const updateResult = await updateBacklinksInFile(
14484
- vaultPath2,
14485
- backlink.path,
14486
- allSourceTitles,
14487
- targetTitle
14488
- );
14489
- if (updateResult.updated) {
14490
- totalBacklinksUpdated += updateResult.linksUpdated;
14582
+ if (dry_run) {
14583
+ for (const backlink of backlinks) {
14584
+ if (backlink.path === source_path || backlink.path === target_path) continue;
14585
+ totalBacklinksUpdated += backlink.links.length;
14491
14586
  modifiedFiles.push(backlink.path);
14492
14587
  }
14588
+ } else {
14589
+ for (const backlink of backlinks) {
14590
+ if (backlink.path === source_path || backlink.path === target_path) continue;
14591
+ const updateResult = await updateBacklinksInFile(
14592
+ vaultPath2,
14593
+ backlink.path,
14594
+ allSourceTitles,
14595
+ targetTitle
14596
+ );
14597
+ if (updateResult.updated) {
14598
+ totalBacklinksUpdated += updateResult.linksUpdated;
14599
+ modifiedFiles.push(backlink.path);
14600
+ }
14601
+ }
14602
+ }
14603
+ const previewLines = [
14604
+ `${dry_run ? "Would merge" : "Merged"}: "${sourceTitle}" \u2192 "${targetTitle}"`,
14605
+ `Aliases ${dry_run ? "to add" : "added"}: ${allNewAliases.join(", ")}`,
14606
+ `Source content ${dry_run ? "to append" : "appended"}: ${trimmedSource.length > 10 ? "yes" : "no"}`,
14607
+ `Backlinks ${dry_run ? "to update" : "updated"}: ${totalBacklinksUpdated}`
14608
+ ];
14609
+ if (modifiedFiles.length > 0) {
14610
+ previewLines.push(`Files ${dry_run ? "to modify" : "modified"}: ${modifiedFiles.join(", ")}`);
14611
+ }
14612
+ if (dry_run) {
14613
+ const result2 = {
14614
+ success: true,
14615
+ message: `[dry run] Would merge "${sourceTitle}" into "${targetTitle}"`,
14616
+ path: target_path,
14617
+ preview: previewLines.join("\n"),
14618
+ backlinks_updated: totalBacklinksUpdated,
14619
+ dryRun: true
14620
+ };
14621
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
14493
14622
  }
14494
14623
  await writeVaultFile(vaultPath2, target_path, targetContent, targetFrontmatter);
14495
14624
  const fullSourcePath = `${vaultPath2}/${source_path}`;
@@ -14497,15 +14626,6 @@ ${trimmedSource}`;
14497
14626
  initializeEntityIndex(vaultPath2).catch((err) => {
14498
14627
  console.error(`[Flywheel] Entity cache rebuild failed: ${err}`);
14499
14628
  });
14500
- const previewLines = [
14501
- `Merged: "${sourceTitle}" \u2192 "${targetTitle}"`,
14502
- `Aliases added: ${allNewAliases.join(", ")}`,
14503
- `Source content appended: ${trimmedSource.length > 10 ? "yes" : "no"}`,
14504
- `Backlinks updated: ${totalBacklinksUpdated}`
14505
- ];
14506
- if (modifiedFiles.length > 0) {
14507
- previewLines.push(`Files modified: ${modifiedFiles.join(", ")}`);
14508
- }
14509
14629
  const result = {
14510
14630
  success: true,
14511
14631
  message: `Merged "${sourceTitle}" into "${targetTitle}"`,
@@ -14529,9 +14649,10 @@ ${trimmedSource}`;
14529
14649
  "Absorb an entity name as an alias of a target note: adds alias to target frontmatter and rewrites all [[source]] links to [[target|source]]. Lighter than merge_entities \u2014 no source note required, no content append, no deletion.",
14530
14650
  {
14531
14651
  source_name: z16.string().describe('The entity name to absorb (e.g. "Foo")'),
14532
- target_path: z16.string().describe('Vault-relative path of the target entity note (e.g. "entities/Bar.md")')
14652
+ target_path: z16.string().describe('Vault-relative path of the target entity note (e.g. "entities/Bar.md")'),
14653
+ dry_run: z16.boolean().optional().default(false).describe("Preview what would change without modifying any files")
14533
14654
  },
14534
- async ({ source_name, target_path }) => {
14655
+ async ({ source_name, target_path, dry_run }) => {
14535
14656
  try {
14536
14657
  if (!validatePath(vaultPath2, target_path)) {
14537
14658
  const result2 = {
@@ -14562,58 +14683,67 @@ ${trimmedSource}`;
14562
14683
  deduped.add(source_name);
14563
14684
  }
14564
14685
  targetFrontmatter.aliases = Array.from(deduped);
14565
- await writeVaultFile(vaultPath2, target_path, targetContent, targetFrontmatter);
14566
14686
  const backlinks = await findBacklinks(vaultPath2, source_name, []);
14567
14687
  let totalBacklinksUpdated = 0;
14568
14688
  const modifiedFiles = [];
14569
- for (const backlink of backlinks) {
14570
- if (backlink.path === target_path) continue;
14571
- let fileData;
14572
- try {
14573
- fileData = await readVaultFile(vaultPath2, backlink.path);
14574
- } catch {
14575
- continue;
14689
+ if (dry_run) {
14690
+ for (const backlink of backlinks) {
14691
+ if (backlink.path === target_path) continue;
14692
+ totalBacklinksUpdated += backlink.links.length;
14693
+ modifiedFiles.push(backlink.path);
14576
14694
  }
14577
- let content = fileData.content;
14578
- let linksUpdated = 0;
14579
- const pattern = new RegExp(
14580
- `\\[\\[${escapeRegex(source_name)}(\\|[^\\]]+)?\\]\\]`,
14581
- "gi"
14582
- );
14583
- content = content.replace(pattern, (_match, displayPart) => {
14584
- linksUpdated++;
14585
- if (displayPart) {
14586
- return `[[${targetTitle}${displayPart}]]`;
14695
+ } else {
14696
+ await writeVaultFile(vaultPath2, target_path, targetContent, targetFrontmatter);
14697
+ for (const backlink of backlinks) {
14698
+ if (backlink.path === target_path) continue;
14699
+ let fileData;
14700
+ try {
14701
+ fileData = await readVaultFile(vaultPath2, backlink.path);
14702
+ } catch {
14703
+ continue;
14587
14704
  }
14588
- if (source_name.toLowerCase() === targetTitle.toLowerCase()) {
14589
- return `[[${targetTitle}]]`;
14705
+ let content = fileData.content;
14706
+ let linksUpdated = 0;
14707
+ const pattern = new RegExp(
14708
+ `\\[\\[${escapeRegex(source_name)}(\\|[^\\]]+)?\\]\\]`,
14709
+ "gi"
14710
+ );
14711
+ content = content.replace(pattern, (_match, displayPart) => {
14712
+ linksUpdated++;
14713
+ if (displayPart) {
14714
+ return `[[${targetTitle}${displayPart}]]`;
14715
+ }
14716
+ if (source_name.toLowerCase() === targetTitle.toLowerCase()) {
14717
+ return `[[${targetTitle}]]`;
14718
+ }
14719
+ return `[[${targetTitle}|${source_name}]]`;
14720
+ });
14721
+ if (linksUpdated > 0) {
14722
+ await writeVaultFile(vaultPath2, backlink.path, content, fileData.frontmatter);
14723
+ totalBacklinksUpdated += linksUpdated;
14724
+ modifiedFiles.push(backlink.path);
14590
14725
  }
14591
- return `[[${targetTitle}|${source_name}]]`;
14592
- });
14593
- if (linksUpdated > 0) {
14594
- await writeVaultFile(vaultPath2, backlink.path, content, fileData.frontmatter);
14595
- totalBacklinksUpdated += linksUpdated;
14596
- modifiedFiles.push(backlink.path);
14597
14726
  }
14727
+ initializeEntityIndex(vaultPath2).catch((err) => {
14728
+ console.error(`[Flywheel] Entity cache rebuild failed: ${err}`);
14729
+ });
14598
14730
  }
14599
- initializeEntityIndex(vaultPath2).catch((err) => {
14600
- console.error(`[Flywheel] Entity cache rebuild failed: ${err}`);
14601
- });
14602
14731
  const aliasAdded = source_name.toLowerCase() !== targetTitle.toLowerCase();
14603
14732
  const previewLines = [
14604
- `Absorbed: "${source_name}" \u2192 "${targetTitle}"`,
14605
- `Alias added: ${aliasAdded ? source_name : "no (matches target title)"}`,
14606
- `Backlinks updated: ${totalBacklinksUpdated}`
14733
+ `${dry_run ? "Would absorb" : "Absorbed"}: "${source_name}" \u2192 "${targetTitle}"`,
14734
+ `Alias ${dry_run ? "to add" : "added"}: ${aliasAdded ? source_name : "no (matches target title)"}`,
14735
+ `Backlinks ${dry_run ? "to update" : "updated"}: ${totalBacklinksUpdated}`
14607
14736
  ];
14608
14737
  if (modifiedFiles.length > 0) {
14609
- previewLines.push(`Files modified: ${modifiedFiles.join(", ")}`);
14738
+ previewLines.push(`Files ${dry_run ? "to modify" : "modified"}: ${modifiedFiles.join(", ")}`);
14610
14739
  }
14611
14740
  const result = {
14612
14741
  success: true,
14613
- message: `Absorbed "${source_name}" as alias of "${targetTitle}"`,
14742
+ message: dry_run ? `[dry run] Would absorb "${source_name}" as alias of "${targetTitle}"` : `Absorbed "${source_name}" as alias of "${targetTitle}"`,
14614
14743
  path: target_path,
14615
14744
  preview: previewLines.join("\n"),
14616
- backlinks_updated: totalBacklinksUpdated
14745
+ backlinks_updated: totalBacklinksUpdated,
14746
+ ...dry_run ? { dryRun: true } : {}
14617
14747
  };
14618
14748
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
14619
14749
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.0.58",
3
+ "version": "2.0.60",
4
4
  "description": "MCP server that gives Claude full read/write access to your Obsidian vault. Select from 42 tools for search, backlinks, graph queries, mutations, and hybrid semantic search.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@modelcontextprotocol/sdk": "^1.25.1",
55
- "@velvetmonkey/vault-core": "^2.0.58",
55
+ "@velvetmonkey/vault-core": "^2.0.60",
56
56
  "better-sqlite3": "^11.0.0",
57
57
  "chokidar": "^4.0.0",
58
58
  "gray-matter": "^4.0.3",