@velvetmonkey/flywheel-memory 2.0.8 → 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 +243 -30
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -567,6 +567,7 @@ function injectMutationMetadata(frontmatter, scoping) {
567
567
  if (scoping.session_id) {
568
568
  frontmatter._session_id = scoping.session_id;
569
569
  }
570
+ frontmatter._source = "ai";
570
571
  if (scoping.agent_id && scoping.session_id) {
571
572
  frontmatter._last_modified_by = `${scoping.agent_id}:${scoping.session_id}`;
572
573
  } else if (scoping.agent_id) {
@@ -5022,6 +5023,82 @@ function getLinkPath(index, fromPath, toPath, maxDepth = 10) {
5022
5023
  }
5023
5024
  return { exists: false, path: [], length: -1 };
5024
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
+ }
5025
5102
  function getCommonNeighbors(index, noteAPath, noteBPath) {
5026
5103
  const noteA = index.notes.get(noteAPath);
5027
5104
  const noteB = index.notes.get(noteBPath);
@@ -6102,15 +6179,20 @@ function matchesFrontmatter(note, where) {
6102
6179
  }
6103
6180
  return true;
6104
6181
  }
6105
- function hasTag(note, tag) {
6182
+ function hasTag(note, tag, includeChildren = false) {
6106
6183
  const normalizedTag = tag.replace(/^#/, "").toLowerCase();
6107
- 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
+ });
6108
6190
  }
6109
- function hasAnyTag(note, tags) {
6110
- return tags.some((tag) => hasTag(note, tag));
6191
+ function hasAnyTag(note, tags, includeChildren = false) {
6192
+ return tags.some((tag) => hasTag(note, tag, includeChildren));
6111
6193
  }
6112
- function hasAllTags(note, tags) {
6113
- return tags.every((tag) => hasTag(note, tag));
6194
+ function hasAllTags(note, tags, includeChildren = false) {
6195
+ return tags.every((tag) => hasTag(note, tag, includeChildren));
6114
6196
  }
6115
6197
  function inFolder(note, folder) {
6116
6198
  const normalizedFolder = folder.endsWith("/") ? folder : folder + "/";
@@ -6140,7 +6222,7 @@ function sortNotes(notes, sortBy, order) {
6140
6222
  function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
6141
6223
  server2.tool(
6142
6224
  "search",
6143
- 'Search the vault across metadata, content, and entities. Scope controls what to search: "metadata" for frontmatter/tags/folders, "content" for full-text search (FTS5), "entities" for people/projects/technologies, "all" (default) tries metadata then falls back to content search.',
6225
+ 'Search the vault across metadata, content, and entities. Scope controls what to search: "metadata" for frontmatter/tags/folders, "content" for full-text search (FTS5), "entities" for people/projects/technologies, "all" (default) tries metadata then falls back to content search.\n\nExample: search({ query: "quarterly review", scope: "content", limit: 5 })\nExample: search({ where: { type: "project", status: "active" }, scope: "metadata" })',
6144
6226
  {
6145
6227
  query: z4.string().optional().describe('Search query text. Required for scope "content", "entities", "all". For "metadata" scope, use filters instead.'),
6146
6228
  scope: z4.enum(["metadata", "content", "entities", "all"]).default("all").describe("What to search: metadata (frontmatter/tags/folders), content (FTS5 full-text), entities (people/projects), all (metadata then content)"),
@@ -6149,6 +6231,7 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
6149
6231
  has_tag: z4.string().optional().describe("Filter to notes with this tag"),
6150
6232
  has_any_tag: z4.array(z4.string()).optional().describe("Filter to notes with any of these tags"),
6151
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")'),
6152
6235
  folder: z4.string().optional().describe("Limit to notes in this folder"),
6153
6236
  title_contains: z4.string().optional().describe("Filter to notes whose title contains this text (case-insensitive)"),
6154
6237
  // Date filters (absorbs temporal tools)
@@ -6162,7 +6245,7 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
6162
6245
  // Pagination
6163
6246
  limit: z4.number().default(20).describe("Maximum number of results to return")
6164
6247
  },
6165
- 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 }) => {
6166
6249
  const limit = Math.min(requestedLimit ?? 20, MAX_LIMIT);
6167
6250
  const index = getIndex();
6168
6251
  const vaultPath2 = getVaultPath();
@@ -6188,13 +6271,13 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
6188
6271
  matchingNotes = matchingNotes.filter((note) => matchesFrontmatter(note, where));
6189
6272
  }
6190
6273
  if (has_tag) {
6191
- matchingNotes = matchingNotes.filter((note) => hasTag(note, has_tag));
6274
+ matchingNotes = matchingNotes.filter((note) => hasTag(note, has_tag, include_children));
6192
6275
  }
6193
6276
  if (has_any_tag && has_any_tag.length > 0) {
6194
- matchingNotes = matchingNotes.filter((note) => hasAnyTag(note, has_any_tag));
6277
+ matchingNotes = matchingNotes.filter((note) => hasAnyTag(note, has_any_tag, include_children));
6195
6278
  }
6196
6279
  if (has_all_tags && has_all_tags.length > 0) {
6197
- matchingNotes = matchingNotes.filter((note) => hasAllTags(note, has_all_tags));
6280
+ matchingNotes = matchingNotes.filter((note) => hasAllTags(note, has_all_tags, include_children));
6198
6281
  }
6199
6282
  if (folder) {
6200
6283
  matchingNotes = matchingNotes.filter((note) => inFolder(note, folder));
@@ -7135,16 +7218,17 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
7135
7218
  "get_link_path",
7136
7219
  {
7137
7220
  title: "Get Link Path",
7138
- 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.",
7139
7222
  inputSchema: {
7140
7223
  from: z6.string().describe("Starting note path"),
7141
7224
  to: z6.string().describe("Target note path"),
7142
- 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")
7143
7227
  }
7144
7228
  },
7145
- async ({ from, to, max_depth }) => {
7229
+ async ({ from, to, max_depth, weighted }) => {
7146
7230
  const index = getIndex();
7147
- const result = getLinkPath(index, from, to, max_depth);
7231
+ const result = weighted ? getWeightedLinkPath(index, from, to, max_depth) : getLinkPath(index, from, to, max_depth);
7148
7232
  return {
7149
7233
  content: [{ type: "text", text: JSON.stringify({
7150
7234
  from,
@@ -7411,7 +7495,7 @@ function registerGraphAnalysisTools(server2, getIndex, getVaultPath) {
7411
7495
  "graph_analysis",
7412
7496
  {
7413
7497
  title: "Graph Analysis",
7414
- description: 'Analyze vault link graph structure. Use analysis to pick the mode:\n- "orphans": Notes with no backlinks (disconnected content)\n- "dead_ends": Notes with backlinks but no outgoing links\n- "sources": Notes with outgoing links but no backlinks\n- "hubs": Highly connected notes (many links to/from)\n- "stale": Important notes (by backlink count) not recently modified',
7498
+ description: 'Analyze vault link graph structure. Use analysis to pick the mode:\n- "orphans": Notes with no backlinks (disconnected content)\n- "dead_ends": Notes with backlinks but no outgoing links\n- "sources": Notes with outgoing links but no backlinks\n- "hubs": Highly connected notes (many links to/from)\n- "stale": Important notes (by backlink count) not recently modified\n\nExample: graph_analysis({ analysis: "hubs", limit: 10 })\nExample: graph_analysis({ analysis: "stale", days: 30, min_backlinks: 3 })',
7415
7499
  inputSchema: {
7416
7500
  analysis: z8.enum(["orphans", "dead_ends", "sources", "hubs", "stale"]).describe("Type of graph analysis to perform"),
7417
7501
  folder: z8.string().optional().describe("Limit to notes in this folder (orphans, dead_ends, sources)"),
@@ -7984,7 +8068,7 @@ function registerVaultSchemaTools(server2, getIndex, getVaultPath) {
7984
8068
  "vault_schema",
7985
8069
  {
7986
8070
  title: "Vault Schema",
7987
- description: 'Analyze and validate vault frontmatter schema. Use analysis to pick the mode:\n- "overview": Schema of all frontmatter fields across the vault\n- "field_values": All unique values for a specific field\n- "inconsistencies": Fields with multiple types across notes\n- "validate": Validate notes against a provided schema\n- "missing": Find notes missing expected fields by folder\n- "conventions": Auto-detect metadata conventions for a folder\n- "incomplete": Find notes missing expected fields (inferred)\n- "suggest_values": Suggest values for a field based on usage',
8071
+ description: 'Analyze and validate vault frontmatter schema. Use analysis to pick the mode:\n- "overview": Schema of all frontmatter fields across the vault\n- "field_values": All unique values for a specific field\n- "inconsistencies": Fields with multiple types across notes\n- "validate": Validate notes against a provided schema\n- "missing": Find notes missing expected fields by folder\n- "conventions": Auto-detect metadata conventions for a folder\n- "incomplete": Find notes missing expected fields (inferred)\n- "suggest_values": Suggest values for a field based on usage\n\nExample: vault_schema({ analysis: "field_values", field: "status" })\nExample: vault_schema({ analysis: "conventions", folder: "projects" })',
7988
8072
  inputSchema: {
7989
8073
  analysis: z9.enum([
7990
8074
  "overview",
@@ -8554,7 +8638,7 @@ function registerNoteIntelligenceTools(server2, getIndex, getVaultPath) {
8554
8638
  "note_intelligence",
8555
8639
  {
8556
8640
  title: "Note Intelligence",
8557
- description: 'Analyze a note for patterns, suggestions, and consistency. Use analysis to pick the mode:\n- "prose_patterns": Find "Key: Value" or "Key: [[wikilink]]" patterns in prose\n- "suggest_frontmatter": Suggest YAML frontmatter from detected prose patterns\n- "suggest_wikilinks": Find frontmatter values that could be wikilinks\n- "cross_layer": Check consistency between frontmatter and prose references\n- "compute": Auto-compute derived fields (word_count, link_count, etc.)\n- "all": Run all analyses and return combined result',
8641
+ description: 'Analyze a note for patterns, suggestions, and consistency. Use analysis to pick the mode:\n- "prose_patterns": Find "Key: Value" or "Key: [[wikilink]]" patterns in prose\n- "suggest_frontmatter": Suggest YAML frontmatter from detected prose patterns\n- "suggest_wikilinks": Find frontmatter values that could be wikilinks\n- "cross_layer": Check consistency between frontmatter and prose references\n- "compute": Auto-compute derived fields (word_count, link_count, etc.)\n- "all": Run all analyses and return combined result\n\nExample: note_intelligence({ path: "projects/alpha.md", analysis: "wikilinks" })\nExample: note_intelligence({ path: "projects/alpha.md", analysis: "compute", fields: ["word_count", "link_count"] })',
8558
8642
  inputSchema: {
8559
8643
  analysis: z10.enum([
8560
8644
  "prose_patterns",
@@ -9031,7 +9115,9 @@ async function createNoteFromTemplate(vaultPath2, notePath, config) {
9031
9115
  function registerMutationTools(server2, vaultPath2, getConfig = () => ({})) {
9032
9116
  server2.tool(
9033
9117
  "vault_add_to_section",
9034
- "Add content to a specific section in a markdown note. Set create_if_missing=true to auto-create the note from template if it doesn't exist (enables 1-call daily capture).",
9118
+ `Add content to a specific section in a markdown note. Set create_if_missing=true to auto-create the note from template if it doesn't exist (enables 1-call daily capture).
9119
+
9120
+ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", content: "Met with team about Q1", format: "timestamp-bullet", create_if_missing: true })`,
9035
9121
  {
9036
9122
  path: z11.string().describe('Vault-relative path to the note (e.g., "daily-notes/2026-01-28.md")'),
9037
9123
  section: z11.string().describe('Heading text to add to (e.g., "Log" or "## Log")'),
@@ -9295,7 +9381,7 @@ function toggleTask(content, lineNumber) {
9295
9381
  function registerTaskTools(server2, vaultPath2) {
9296
9382
  server2.tool(
9297
9383
  "vault_toggle_task",
9298
- "Toggle a task checkbox between checked and unchecked",
9384
+ 'Toggle a task checkbox between checked and unchecked.\n\nExample: vault_toggle_task({ path: "daily/2026-02-15.md", task: "review PR", section: "Tasks" })',
9299
9385
  {
9300
9386
  path: z12.string().describe("Vault-relative path to the note"),
9301
9387
  task: z12.string().describe("Task text to find (partial match supported)"),
@@ -9355,7 +9441,7 @@ function registerTaskTools(server2, vaultPath2) {
9355
9441
  );
9356
9442
  server2.tool(
9357
9443
  "vault_add_task",
9358
- "Add a new task to a section in a markdown note",
9444
+ 'Add a new task to a section in a markdown note.\n\nExample: vault_add_task({ path: "daily/2026-02-15.md", section: "Tasks", task: "Write unit tests for auth module" })',
9359
9445
  {
9360
9446
  path: z12.string().describe("Vault-relative path to the note"),
9361
9447
  section: z12.string().describe("Section to add the task to"),
@@ -9433,7 +9519,9 @@ import { z as z13 } from "zod";
9433
9519
  function registerFrontmatterTools(server2, vaultPath2) {
9434
9520
  server2.tool(
9435
9521
  "vault_update_frontmatter",
9436
- "Update frontmatter fields in a note (merge with existing). Set only_if_missing=true to only add fields that don't already exist (absorbed vault_add_frontmatter_field).",
9522
+ `Update frontmatter fields in a note (merge with existing). Set only_if_missing=true to only add fields that don't already exist (absorbed vault_add_frontmatter_field).
9523
+
9524
+ Example: vault_update_frontmatter({ path: "projects/alpha.md", frontmatter: { status: "active", priority: 1 }, only_if_missing: true })`,
9437
9525
  {
9438
9526
  path: z13.string().describe("Vault-relative path to the note"),
9439
9527
  frontmatter: z13.record(z13.any()).describe("Frontmatter fields to update (JSON object)"),
@@ -9494,10 +9582,11 @@ import path18 from "path";
9494
9582
  function registerNoteTools(server2, vaultPath2, getIndex) {
9495
9583
  server2.tool(
9496
9584
  "vault_create_note",
9497
- "Create a new note in the vault with optional frontmatter and content",
9585
+ 'Create a new note in the vault with optional frontmatter and content.\n\nExample: vault_create_note({ path: "people/Jane Smith.md", content: "# Jane Smith\\n\\nProduct manager at Acme.", frontmatter: { type: "person", company: "Acme" } })',
9498
9586
  {
9499
9587
  path: z14.string().describe('Vault-relative path for the new note (e.g., "daily-notes/2026-01-28.md")'),
9500
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).'),
9501
9590
  frontmatter: z14.record(z14.any()).default({}).describe("Frontmatter fields (JSON object)"),
9502
9591
  overwrite: z14.boolean().default(false).describe("If true, overwrite existing file"),
9503
9592
  commit: z14.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
@@ -9507,7 +9596,7 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
9507
9596
  agent_id: z14.string().optional().describe("Agent identifier for multi-agent scoping"),
9508
9597
  session_id: z14.string().optional().describe("Session identifier for conversation scoping")
9509
9598
  },
9510
- 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 }) => {
9511
9600
  try {
9512
9601
  if (!validatePath(vaultPath2, notePath)) {
9513
9602
  return formatMcpResult(errorResult(notePath, "Invalid path: path traversal not allowed"));
@@ -9519,9 +9608,29 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
9519
9608
  }
9520
9609
  const dir = path18.dirname(fullPath);
9521
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
+ }
9522
9631
  const warnings = [];
9523
9632
  const noteName = path18.basename(notePath, ".md");
9524
- 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") : [];
9525
9634
  const preflight = checkPreflightSimilarity(noteName);
9526
9635
  if (preflight.existingEntity) {
9527
9636
  warnings.push({
@@ -9545,7 +9654,7 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
9545
9654
  suggestion: `This may cause ambiguous wikilink resolution`
9546
9655
  });
9547
9656
  }
9548
- let { content: processedContent, wikilinkInfo } = maybeApplyWikilinks(content, skipWikilinks, notePath);
9657
+ let { content: processedContent, wikilinkInfo } = maybeApplyWikilinks(effectiveContent, skipWikilinks, notePath);
9549
9658
  let suggestInfo;
9550
9659
  if (suggestOutgoingLinks && !skipWikilinks) {
9551
9660
  const result = suggestRelatedLinks(processedContent, { maxSuggestions, notePath });
@@ -9554,21 +9663,21 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
9554
9663
  suggestInfo = `Suggested: ${result.suggestions.join(", ")}`;
9555
9664
  }
9556
9665
  }
9557
- let finalFrontmatter = frontmatter;
9666
+ let finalFrontmatter = effectiveFrontmatter;
9558
9667
  if (agent_id || session_id) {
9559
- finalFrontmatter = injectMutationMetadata(frontmatter, { agent_id, session_id });
9668
+ finalFrontmatter = injectMutationMetadata(effectiveFrontmatter, { agent_id, session_id });
9560
9669
  }
9561
9670
  await writeVaultFile(vaultPath2, notePath, processedContent, finalFrontmatter);
9562
9671
  const gitInfo = await handleGitCommit(vaultPath2, notePath, commit, "[Flywheel:Create]");
9563
9672
  const infoLines = [wikilinkInfo, suggestInfo].filter(Boolean);
9564
9673
  const previewLines = [
9565
- `Frontmatter fields: ${Object.keys(frontmatter).join(", ") || "none"}`,
9674
+ `Frontmatter fields: ${Object.keys(effectiveFrontmatter).join(", ") || "none"}`,
9566
9675
  `Content length: ${processedContent.length} chars`
9567
9676
  ];
9568
9677
  if (infoLines.length > 0) {
9569
9678
  previewLines.push(`(${infoLines.join("; ")})`);
9570
9679
  }
9571
- const hasAliases = frontmatter && "aliases" in frontmatter;
9680
+ const hasAliases = effectiveFrontmatter && "aliases" in effectiveFrontmatter;
9572
9681
  if (!hasAliases) {
9573
9682
  const aliasSuggestions = suggestAliases(noteName, existingAliases);
9574
9683
  if (aliasSuggestions.length > 0) {
@@ -11471,6 +11580,109 @@ function registerPolicyTools(server2, vaultPath2) {
11471
11580
  );
11472
11581
  }
11473
11582
 
11583
+ // src/resources/vault.ts
11584
+ function registerVaultResources(server2, getIndex) {
11585
+ server2.registerResource(
11586
+ "vault-stats",
11587
+ "vault://stats",
11588
+ {
11589
+ title: "Vault Statistics",
11590
+ description: "Overview of vault size, tags, links, and orphan count",
11591
+ mimeType: "application/json"
11592
+ },
11593
+ async (uri) => {
11594
+ const index = getIndex();
11595
+ if (!index) {
11596
+ return {
11597
+ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify({ error: "Index not ready" }) }]
11598
+ };
11599
+ }
11600
+ const noteCount = index.notes.size;
11601
+ const tagCount = index.tags.size;
11602
+ let totalLinks = 0;
11603
+ for (const note of index.notes.values()) {
11604
+ totalLinks += note.outlinks.length;
11605
+ }
11606
+ let orphanCount = 0;
11607
+ for (const note of index.notes.values()) {
11608
+ const normalizedTitle = note.title.toLowerCase();
11609
+ const backlinks = index.backlinks.get(normalizedTitle);
11610
+ if (!backlinks || backlinks.length === 0) {
11611
+ orphanCount++;
11612
+ }
11613
+ }
11614
+ const stats = {
11615
+ note_count: noteCount,
11616
+ tag_count: tagCount,
11617
+ total_links: totalLinks,
11618
+ orphan_count: orphanCount,
11619
+ index_built_at: index.builtAt.toISOString()
11620
+ };
11621
+ return {
11622
+ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(stats, null, 2) }]
11623
+ };
11624
+ }
11625
+ );
11626
+ server2.registerResource(
11627
+ "vault-schema",
11628
+ "vault://schema",
11629
+ {
11630
+ title: "Vault Schema",
11631
+ description: "Frontmatter field summary: field names, types, frequency",
11632
+ mimeType: "application/json"
11633
+ },
11634
+ async (uri) => {
11635
+ const index = getIndex();
11636
+ if (!index) {
11637
+ return {
11638
+ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify({ error: "Index not ready" }) }]
11639
+ };
11640
+ }
11641
+ const schema = getFrontmatterSchema(index);
11642
+ const compact = {
11643
+ total_notes: schema.total_notes,
11644
+ notes_with_frontmatter: schema.notes_with_frontmatter,
11645
+ field_count: schema.field_count,
11646
+ fields: schema.fields.map((f) => ({
11647
+ name: f.name,
11648
+ types: f.types,
11649
+ count: f.count,
11650
+ examples: f.examples.slice(0, 3)
11651
+ }))
11652
+ };
11653
+ return {
11654
+ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(compact, null, 2) }]
11655
+ };
11656
+ }
11657
+ );
11658
+ server2.registerResource(
11659
+ "vault-recent",
11660
+ "vault://recent",
11661
+ {
11662
+ title: "Recently Modified Notes",
11663
+ description: "Last 10 modified notes in the vault",
11664
+ mimeType: "application/json"
11665
+ },
11666
+ async (uri) => {
11667
+ const index = getIndex();
11668
+ if (!index) {
11669
+ return {
11670
+ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify({ error: "Index not ready" }) }]
11671
+ };
11672
+ }
11673
+ const notes = Array.from(index.notes.values()).sort((a, b) => b.modified.getTime() - a.modified.getTime()).slice(0, 10).map((n) => ({
11674
+ path: n.path,
11675
+ title: n.title,
11676
+ modified: n.modified.toISOString(),
11677
+ tags: n.tags
11678
+ }));
11679
+ return {
11680
+ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify({ recent_notes: notes }, null, 2) }]
11681
+ };
11682
+ }
11683
+ );
11684
+ }
11685
+
11474
11686
  // src/index.ts
11475
11687
  var vaultPath = process.env.PROJECT_PATH || process.env.VAULT_PATH || findVaultRoot();
11476
11688
  var vaultIndex;
@@ -11661,6 +11873,7 @@ registerNoteTools(server, vaultPath, () => vaultIndex);
11661
11873
  registerMoveNoteTools(server, vaultPath);
11662
11874
  registerSystemTools2(server, vaultPath);
11663
11875
  registerPolicyTools(server, vaultPath);
11876
+ registerVaultResources(server, () => vaultIndex ?? null);
11664
11877
  console.error(`[Memory] Registered ${_registeredCount} tools, skipped ${_skippedCount}`);
11665
11878
  async function main() {
11666
11879
  console.error(`[Memory] Starting Flywheel Memory server...`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.0.8",
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.8",
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",