@velvetmonkey/flywheel-memory 2.0.9 → 2.0.10

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 +125 -21
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -5023,6 +5023,82 @@ function getLinkPath(index, fromPath, toPath, maxDepth = 10) {
5023
5023
  }
5024
5024
  return { exists: false, path: [], length: -1 };
5025
5025
  }
5026
+ function getWeightedLinkPath(index, fromPath, toPath, maxDepth = 10) {
5027
+ const from = index.notes.has(fromPath) ? fromPath : resolveTarget(index, fromPath);
5028
+ const to = index.notes.has(toPath) ? toPath : resolveTarget(index, toPath);
5029
+ if (!from || !to) {
5030
+ return { exists: false, path: [], length: -1, total_weight: 0, weights: [] };
5031
+ }
5032
+ if (from === to) {
5033
+ return { exists: true, path: [from], length: 0, total_weight: 0, weights: [] };
5034
+ }
5035
+ const hubNotes = findHubNotes(index, 0);
5036
+ const connectionCounts = /* @__PURE__ */ new Map();
5037
+ for (const hub of hubNotes) {
5038
+ connectionCounts.set(hub.path, hub.total_connections);
5039
+ }
5040
+ const dist = /* @__PURE__ */ new Map();
5041
+ const prev = /* @__PURE__ */ new Map();
5042
+ const depthMap = /* @__PURE__ */ new Map();
5043
+ dist.set(from, 0);
5044
+ depthMap.set(from, 0);
5045
+ const pq = [{ node: from, cost: 0 }];
5046
+ const visited = /* @__PURE__ */ new Set();
5047
+ while (pq.length > 0) {
5048
+ const { node: current, cost: currentCost } = pq.shift();
5049
+ if (current === to) break;
5050
+ if (visited.has(current)) continue;
5051
+ visited.add(current);
5052
+ const currentDepth = depthMap.get(current) ?? 0;
5053
+ if (currentDepth >= maxDepth) continue;
5054
+ const note = index.notes.get(current);
5055
+ if (!note) continue;
5056
+ for (const link of note.outlinks) {
5057
+ const targetPath = resolveTarget(index, link.target);
5058
+ if (!targetPath || visited.has(targetPath)) continue;
5059
+ const strength = getConnectionStrength(index, current, targetPath);
5060
+ const baseWeight = 1 / (1 + strength.score);
5061
+ const targetConnections = connectionCounts.get(targetPath) ?? 0;
5062
+ const hubPenalty = targetConnections > 0 ? 1 + Math.log2(targetConnections) : 1;
5063
+ const edgeWeight = baseWeight * hubPenalty;
5064
+ const newDist = currentCost + edgeWeight;
5065
+ const existingDist = dist.get(targetPath);
5066
+ if (existingDist === void 0 || newDist < existingDist) {
5067
+ dist.set(targetPath, newDist);
5068
+ prev.set(targetPath, current);
5069
+ depthMap.set(targetPath, currentDepth + 1);
5070
+ const entry = { node: targetPath, cost: newDist };
5071
+ const insertIdx = pq.findIndex((e) => e.cost > newDist);
5072
+ if (insertIdx === -1) {
5073
+ pq.push(entry);
5074
+ } else {
5075
+ pq.splice(insertIdx, 0, entry);
5076
+ }
5077
+ }
5078
+ }
5079
+ }
5080
+ if (!dist.has(to)) {
5081
+ return { exists: false, path: [], length: -1, total_weight: 0, weights: [] };
5082
+ }
5083
+ const resultPath = [];
5084
+ let node = to;
5085
+ while (node) {
5086
+ resultPath.unshift(node);
5087
+ node = prev.get(node);
5088
+ }
5089
+ const weights = [];
5090
+ for (let i = 0; i < resultPath.length - 1; i++) {
5091
+ const edgeDist = (dist.get(resultPath[i + 1]) ?? 0) - (dist.get(resultPath[i]) ?? 0);
5092
+ weights.push(Math.round(edgeDist * 1e3) / 1e3);
5093
+ }
5094
+ return {
5095
+ exists: true,
5096
+ path: resultPath,
5097
+ length: resultPath.length - 1,
5098
+ total_weight: Math.round((dist.get(to) ?? 0) * 1e3) / 1e3,
5099
+ weights
5100
+ };
5101
+ }
5026
5102
  function getCommonNeighbors(index, noteAPath, noteBPath) {
5027
5103
  const noteA = index.notes.get(noteAPath);
5028
5104
  const noteB = index.notes.get(noteBPath);
@@ -6103,15 +6179,20 @@ function matchesFrontmatter(note, where) {
6103
6179
  }
6104
6180
  return true;
6105
6181
  }
6106
- function hasTag(note, tag) {
6182
+ function hasTag(note, tag, includeChildren = false) {
6107
6183
  const normalizedTag = tag.replace(/^#/, "").toLowerCase();
6108
- return note.tags.some((t) => t.toLowerCase() === normalizedTag);
6184
+ return note.tags.some((t) => {
6185
+ const normalizedNoteTag = t.toLowerCase();
6186
+ if (normalizedNoteTag === normalizedTag) return true;
6187
+ if (includeChildren && normalizedNoteTag.startsWith(normalizedTag + "/")) return true;
6188
+ return false;
6189
+ });
6109
6190
  }
6110
- function hasAnyTag(note, tags) {
6111
- return tags.some((tag) => hasTag(note, tag));
6191
+ function hasAnyTag(note, tags, includeChildren = false) {
6192
+ return tags.some((tag) => hasTag(note, tag, includeChildren));
6112
6193
  }
6113
- function hasAllTags(note, tags) {
6114
- return tags.every((tag) => hasTag(note, tag));
6194
+ function hasAllTags(note, tags, includeChildren = false) {
6195
+ return tags.every((tag) => hasTag(note, tag, includeChildren));
6115
6196
  }
6116
6197
  function inFolder(note, folder) {
6117
6198
  const normalizedFolder = folder.endsWith("/") ? folder : folder + "/";
@@ -6150,6 +6231,7 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
6150
6231
  has_tag: z4.string().optional().describe("Filter to notes with this tag"),
6151
6232
  has_any_tag: z4.array(z4.string()).optional().describe("Filter to notes with any of these tags"),
6152
6233
  has_all_tags: z4.array(z4.string()).optional().describe("Filter to notes with all of these tags"),
6234
+ include_children: z4.boolean().default(false).describe('When true, tag filters also match child tags (e.g., has_tag: "project" also matches "project/active")'),
6153
6235
  folder: z4.string().optional().describe("Limit to notes in this folder"),
6154
6236
  title_contains: z4.string().optional().describe("Filter to notes whose title contains this text (case-insensitive)"),
6155
6237
  // Date filters (absorbs temporal tools)
@@ -6163,7 +6245,7 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
6163
6245
  // Pagination
6164
6246
  limit: z4.number().default(20).describe("Maximum number of results to return")
6165
6247
  },
6166
- async ({ query, scope, where, has_tag, has_any_tag, has_all_tags, folder, title_contains, modified_after, modified_before, sort_by, order, prefix, limit: requestedLimit }) => {
6248
+ async ({ query, scope, where, has_tag, has_any_tag, has_all_tags, include_children, folder, title_contains, modified_after, modified_before, sort_by, order, prefix, limit: requestedLimit }) => {
6167
6249
  const limit = Math.min(requestedLimit ?? 20, MAX_LIMIT);
6168
6250
  const index = getIndex();
6169
6251
  const vaultPath2 = getVaultPath();
@@ -6189,13 +6271,13 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
6189
6271
  matchingNotes = matchingNotes.filter((note) => matchesFrontmatter(note, where));
6190
6272
  }
6191
6273
  if (has_tag) {
6192
- matchingNotes = matchingNotes.filter((note) => hasTag(note, has_tag));
6274
+ matchingNotes = matchingNotes.filter((note) => hasTag(note, has_tag, include_children));
6193
6275
  }
6194
6276
  if (has_any_tag && has_any_tag.length > 0) {
6195
- matchingNotes = matchingNotes.filter((note) => hasAnyTag(note, has_any_tag));
6277
+ matchingNotes = matchingNotes.filter((note) => hasAnyTag(note, has_any_tag, include_children));
6196
6278
  }
6197
6279
  if (has_all_tags && has_all_tags.length > 0) {
6198
- matchingNotes = matchingNotes.filter((note) => hasAllTags(note, has_all_tags));
6280
+ matchingNotes = matchingNotes.filter((note) => hasAllTags(note, has_all_tags, include_children));
6199
6281
  }
6200
6282
  if (folder) {
6201
6283
  matchingNotes = matchingNotes.filter((note) => inFolder(note, folder));
@@ -7136,16 +7218,17 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
7136
7218
  "get_link_path",
7137
7219
  {
7138
7220
  title: "Get Link Path",
7139
- description: "Find the shortest path of links between two notes.",
7221
+ description: "Find the shortest path of links between two notes. Use weighted=true to penalize hub nodes for more meaningful paths.",
7140
7222
  inputSchema: {
7141
7223
  from: z6.string().describe("Starting note path"),
7142
7224
  to: z6.string().describe("Target note path"),
7143
- max_depth: z6.coerce.number().default(10).describe("Maximum path length to search")
7225
+ max_depth: z6.coerce.number().default(10).describe("Maximum path length to search"),
7226
+ weighted: z6.boolean().default(false).describe("Use weighted path-finding that penalizes hub nodes for more meaningful paths")
7144
7227
  }
7145
7228
  },
7146
- async ({ from, to, max_depth }) => {
7229
+ async ({ from, to, max_depth, weighted }) => {
7147
7230
  const index = getIndex();
7148
- const result = getLinkPath(index, from, to, max_depth);
7231
+ const result = weighted ? getWeightedLinkPath(index, from, to, max_depth) : getLinkPath(index, from, to, max_depth);
7149
7232
  return {
7150
7233
  content: [{ type: "text", text: JSON.stringify({
7151
7234
  from,
@@ -9503,6 +9586,7 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
9503
9586
  {
9504
9587
  path: z14.string().describe('Vault-relative path for the new note (e.g., "daily-notes/2026-01-28.md")'),
9505
9588
  content: z14.string().default("").describe("Initial content for the note"),
9589
+ template: z14.string().optional().describe('Vault-relative path to a template file (e.g., "templates/person.md"). Template variables {{date}} and {{title}} are substituted. Template frontmatter is merged with the frontmatter parameter (explicit values take precedence).'),
9506
9590
  frontmatter: z14.record(z14.any()).default({}).describe("Frontmatter fields (JSON object)"),
9507
9591
  overwrite: z14.boolean().default(false).describe("If true, overwrite existing file"),
9508
9592
  commit: z14.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
@@ -9512,7 +9596,7 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
9512
9596
  agent_id: z14.string().optional().describe("Agent identifier for multi-agent scoping"),
9513
9597
  session_id: z14.string().optional().describe("Session identifier for conversation scoping")
9514
9598
  },
9515
- async ({ path: notePath, content, frontmatter, overwrite, commit, skipWikilinks, suggestOutgoingLinks, maxSuggestions, agent_id, session_id }) => {
9599
+ async ({ path: notePath, content, template, frontmatter, overwrite, commit, skipWikilinks, suggestOutgoingLinks, maxSuggestions, agent_id, session_id }) => {
9516
9600
  try {
9517
9601
  if (!validatePath(vaultPath2, notePath)) {
9518
9602
  return formatMcpResult(errorResult(notePath, "Invalid path: path traversal not allowed"));
@@ -9524,9 +9608,29 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
9524
9608
  }
9525
9609
  const dir = path18.dirname(fullPath);
9526
9610
  await fs18.mkdir(dir, { recursive: true });
9611
+ let effectiveContent = content;
9612
+ let effectiveFrontmatter = frontmatter;
9613
+ if (template) {
9614
+ const templatePath = path18.join(vaultPath2, template);
9615
+ try {
9616
+ const raw = await fs18.readFile(templatePath, "utf-8");
9617
+ const matter8 = (await import("gray-matter")).default;
9618
+ const parsed = matter8(raw);
9619
+ const dateStr = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
9620
+ const title = path18.basename(notePath, ".md");
9621
+ let templateContent = parsed.content.replace(/\{\{date\}\}/g, dateStr).replace(/\{\{title\}\}/g, title);
9622
+ if (content) {
9623
+ templateContent = templateContent.trimEnd() + "\n\n" + content;
9624
+ }
9625
+ effectiveContent = templateContent;
9626
+ effectiveFrontmatter = { ...parsed.data || {}, ...frontmatter };
9627
+ } catch {
9628
+ return formatMcpResult(errorResult(notePath, `Template not found: ${template}`));
9629
+ }
9630
+ }
9527
9631
  const warnings = [];
9528
9632
  const noteName = path18.basename(notePath, ".md");
9529
- const existingAliases = Array.isArray(frontmatter?.aliases) ? frontmatter.aliases.filter((a) => typeof a === "string") : [];
9633
+ const existingAliases = Array.isArray(effectiveFrontmatter?.aliases) ? effectiveFrontmatter.aliases.filter((a) => typeof a === "string") : [];
9530
9634
  const preflight = checkPreflightSimilarity(noteName);
9531
9635
  if (preflight.existingEntity) {
9532
9636
  warnings.push({
@@ -9550,7 +9654,7 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
9550
9654
  suggestion: `This may cause ambiguous wikilink resolution`
9551
9655
  });
9552
9656
  }
9553
- let { content: processedContent, wikilinkInfo } = maybeApplyWikilinks(content, skipWikilinks, notePath);
9657
+ let { content: processedContent, wikilinkInfo } = maybeApplyWikilinks(effectiveContent, skipWikilinks, notePath);
9554
9658
  let suggestInfo;
9555
9659
  if (suggestOutgoingLinks && !skipWikilinks) {
9556
9660
  const result = suggestRelatedLinks(processedContent, { maxSuggestions, notePath });
@@ -9559,21 +9663,21 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
9559
9663
  suggestInfo = `Suggested: ${result.suggestions.join(", ")}`;
9560
9664
  }
9561
9665
  }
9562
- let finalFrontmatter = frontmatter;
9666
+ let finalFrontmatter = effectiveFrontmatter;
9563
9667
  if (agent_id || session_id) {
9564
- finalFrontmatter = injectMutationMetadata(frontmatter, { agent_id, session_id });
9668
+ finalFrontmatter = injectMutationMetadata(effectiveFrontmatter, { agent_id, session_id });
9565
9669
  }
9566
9670
  await writeVaultFile(vaultPath2, notePath, processedContent, finalFrontmatter);
9567
9671
  const gitInfo = await handleGitCommit(vaultPath2, notePath, commit, "[Flywheel:Create]");
9568
9672
  const infoLines = [wikilinkInfo, suggestInfo].filter(Boolean);
9569
9673
  const previewLines = [
9570
- `Frontmatter fields: ${Object.keys(frontmatter).join(", ") || "none"}`,
9674
+ `Frontmatter fields: ${Object.keys(effectiveFrontmatter).join(", ") || "none"}`,
9571
9675
  `Content length: ${processedContent.length} chars`
9572
9676
  ];
9573
9677
  if (infoLines.length > 0) {
9574
9678
  previewLines.push(`(${infoLines.join("; ")})`);
9575
9679
  }
9576
- const hasAliases = frontmatter && "aliases" in frontmatter;
9680
+ const hasAliases = effectiveFrontmatter && "aliases" in effectiveFrontmatter;
9577
9681
  if (!hasAliases) {
9578
9682
  const aliasSuggestions = suggestAliases(noteName, existingAliases);
9579
9683
  if (aliasSuggestions.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.0.9",
3
+ "version": "2.0.10",
4
4
  "description": "MCP server that gives Claude full read/write access to your Obsidian vault. 36 tools for search, backlinks, graph queries, and mutations.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@modelcontextprotocol/sdk": "^1.25.1",
53
- "@velvetmonkey/vault-core": "^2.0.9",
53
+ "@velvetmonkey/vault-core": "^2.0.10",
54
54
  "better-sqlite3": "^11.0.0",
55
55
  "chokidar": "^4.0.0",
56
56
  "gray-matter": "^4.0.3",