@velvetmonkey/flywheel-memory 2.0.77 → 2.0.79

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 +314 -258
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -121,7 +121,7 @@ function isEmptyPlaceholder(line) {
121
121
  const trimmed = line.trim();
122
122
  return EMPTY_PLACEHOLDER_PATTERNS.some((p) => p.test(trimmed));
123
123
  }
124
- function extractHeadings2(content) {
124
+ function extractHeadings3(content) {
125
125
  const lines = content.split("\n");
126
126
  const headings = [];
127
127
  let inCodeBlock = false;
@@ -144,7 +144,7 @@ function extractHeadings2(content) {
144
144
  return headings;
145
145
  }
146
146
  function findSection(content, sectionName) {
147
- const headings = extractHeadings2(content);
147
+ const headings = extractHeadings3(content);
148
148
  const lines = content.split("\n");
149
149
  const normalizedSearch = sectionName.replace(/^#+\s*/, "").trim().toLowerCase();
150
150
  const headingIndex = headings.findIndex(
@@ -1707,6 +1707,14 @@ function extractWikilinks(content) {
1707
1707
  }
1708
1708
  return links;
1709
1709
  }
1710
+ function extractHeadings(markdown) {
1711
+ const headings = [];
1712
+ for (const line of markdown.split("\n")) {
1713
+ const match = line.match(/^(#{1,6})\s+(.+)/);
1714
+ if (match) headings.push(match[2].trim());
1715
+ }
1716
+ return headings;
1717
+ }
1710
1718
  function extractTags(content, frontmatter) {
1711
1719
  const tags = /* @__PURE__ */ new Set();
1712
1720
  const fmTags = frontmatter.tags;
@@ -1808,6 +1816,7 @@ async function parseNoteWithWarnings(file) {
1808
1816
  frontmatter,
1809
1817
  outlinks: extractWikilinks(markdown),
1810
1818
  tags: extractTags(markdown, frontmatter),
1819
+ headings: extractHeadings(markdown),
1811
1820
  modified: file.modified,
1812
1821
  created
1813
1822
  },
@@ -7213,7 +7222,7 @@ function searchFTS5(_vaultPath, query, limit = 10) {
7213
7222
  SELECT
7214
7223
  path,
7215
7224
  title,
7216
- snippet(notes_fts, 3, '<mark>', '</mark>', '...', 20) as snippet
7225
+ snippet(notes_fts, 3, '<mark>', '</mark>', '...', 64) as snippet
7217
7226
  FROM notes_fts
7218
7227
  WHERE notes_fts MATCH ?
7219
7228
  ORDER BY bm25(notes_fts, 0.0, 5.0, 10.0, 1.0)
@@ -7231,6 +7240,19 @@ function searchFTS5(_vaultPath, query, limit = 10) {
7231
7240
  function getFTS5State() {
7232
7241
  return { ...state };
7233
7242
  }
7243
+ function getContentPreview(notePath, maxChars = 300) {
7244
+ if (!db2) return null;
7245
+ try {
7246
+ const row = db2.prepare(
7247
+ "SELECT substr(content, 1, ?) as preview FROM notes_fts WHERE path = ?"
7248
+ ).get(maxChars + 50, notePath);
7249
+ if (!row?.preview) return null;
7250
+ const truncated = row.preview.length > maxChars ? row.preview.slice(0, maxChars).replace(/\s\S*$/, "") + "..." : row.preview;
7251
+ return truncated;
7252
+ } catch {
7253
+ return null;
7254
+ }
7255
+ }
7234
7256
  function countFTS5Mentions(term) {
7235
7257
  if (!db2) return 0;
7236
7258
  try {
@@ -9592,7 +9614,8 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
9592
9614
  import { z as z4 } from "zod";
9593
9615
  import {
9594
9616
  searchEntities,
9595
- searchEntitiesPrefix
9617
+ searchEntitiesPrefix,
9618
+ getEntityByName as getEntityByName2
9596
9619
  } from "@velvetmonkey/vault-core";
9597
9620
  function matchesFrontmatter(note, where) {
9598
9621
  for (const [key, value] of Object.entries(where)) {
@@ -9640,6 +9663,50 @@ function inFolder(note, folder) {
9640
9663
  const normalizedFolder = folder.endsWith("/") ? folder : folder + "/";
9641
9664
  return note.path.startsWith(normalizedFolder) || note.path.split("/")[0] === folder.replace("/", "");
9642
9665
  }
9666
+ function enrichResult(result, index, stateDb2) {
9667
+ const note = index.notes.get(result.path);
9668
+ const normalizedPath = result.path.toLowerCase().replace(/\.md$/, "");
9669
+ const backlinks = index.backlinks.get(normalizedPath) || [];
9670
+ const enriched = {
9671
+ path: result.path,
9672
+ title: result.title
9673
+ };
9674
+ if (result.snippet) enriched.snippet = result.snippet;
9675
+ if (note) {
9676
+ enriched.frontmatter = note.frontmatter;
9677
+ enriched.tags = note.tags;
9678
+ enriched.aliases = note.aliases;
9679
+ enriched.backlink_count = backlinks.length;
9680
+ enriched.backlinks = backlinks.map((bl) => ({ source: bl.source, line: bl.line }));
9681
+ enriched.outlink_count = note.outlinks.length;
9682
+ enriched.outlinks = note.outlinks.map((l) => {
9683
+ const targetLower = l.target.toLowerCase();
9684
+ const exists = index.entities.has(targetLower);
9685
+ const out = { target: l.target, line: l.line, exists };
9686
+ if (l.alias) out.alias = l.alias;
9687
+ return out;
9688
+ });
9689
+ enriched.headings = note.headings || [];
9690
+ enriched.modified = note.modified.toISOString();
9691
+ if (note.created) enriched.created = note.created.toISOString();
9692
+ }
9693
+ if (stateDb2) {
9694
+ try {
9695
+ const entity = getEntityByName2(stateDb2, result.title);
9696
+ if (entity) {
9697
+ enriched.category = entity.category;
9698
+ enriched.hub_score = entity.hubScore;
9699
+ if (entity.description) enriched.description = entity.description;
9700
+ }
9701
+ } catch {
9702
+ }
9703
+ }
9704
+ if (!result.snippet) {
9705
+ const preview = getContentPreview(result.path);
9706
+ if (preview) enriched.content_preview = preview;
9707
+ }
9708
+ return enriched;
9709
+ }
9643
9710
  function sortNotes(notes, sortBy, order) {
9644
9711
  const sorted = [...notes];
9645
9712
  sorted.sort((a, b) => {
@@ -9664,7 +9731,7 @@ function sortNotes(notes, sortBy, order) {
9664
9731
  function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
9665
9732
  server2.tool(
9666
9733
  "search",
9667
- '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. When embeddings have been built (via init_semantic), content and all scopes automatically include embedding-based results via hybrid ranking.\n\nExample: search({ query: "quarterly review", scope: "content", limit: 5 })\nExample: search({ where: { type: "project", status: "active" }, scope: "metadata" })',
9734
+ 'Search the vault \u2014 always try this before reading files. Returns frontmatter, backlinks (with lines), outlinks (with lines + exists), headings, content snippet or preview, entity metadata, and timestamps for every hit.\n\nSearch 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. When embeddings have been built (via init_semantic), content and all scopes automatically include embedding-based results via hybrid ranking.\n\nExample: search({ query: "quarterly review", scope: "content", limit: 5 })\nExample: search({ where: { type: "project", status: "active" }, scope: "metadata" })',
9668
9735
  {
9669
9736
  query: z4.string().optional().describe('Search query text. Required for scope "content", "entities", "all". For "metadata" scope, use filters instead.'),
9670
9737
  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). Semantic results are automatically included when embeddings have been built (via init_semantic)."),
@@ -9751,14 +9818,10 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
9751
9818
  matchingNotes = sortNotes(matchingNotes, sort_by ?? "modified", order ?? "desc");
9752
9819
  const totalMatches = matchingNotes.length;
9753
9820
  const limitedNotes = matchingNotes.slice(0, limit);
9754
- const notes = limitedNotes.map((note) => ({
9755
- path: note.path,
9756
- title: note.title,
9757
- modified: note.modified.toISOString(),
9758
- created: note.created?.toISOString(),
9759
- tags: note.tags,
9760
- frontmatter: note.frontmatter
9761
- }));
9821
+ const stateDb2 = getStateDb();
9822
+ const notes = limitedNotes.map(
9823
+ (note) => enrichResult({ path: note.path, title: note.title }, index, stateDb2)
9824
+ );
9762
9825
  if (scope === "metadata" || hasMetadataFilters || totalMatches > 0) {
9763
9826
  return { content: [{ type: "text", text: JSON.stringify({
9764
9827
  scope: "metadata",
@@ -9847,22 +9910,20 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
9847
9910
  const fts5Map = new Map(fts5Results.map((r) => [normalizePath2(r.path), r]));
9848
9911
  const semanticMap = new Map(semanticResults.map((r) => [normalizePath2(r.path), r]));
9849
9912
  const entityMap = new Map(entityResults.map((r) => [normalizePath2(r.path), r]));
9850
- const merged = Array.from(allPaths).map((p) => ({
9851
- path: p,
9852
- title: fts5Map.get(p)?.title || semanticMap.get(p)?.title || entityMap.get(p)?.name || p.replace(/\.md$/, "").split("/").pop() || p,
9853
- snippet: fts5Map.get(p)?.snippet,
9854
- rrf_score: Math.round((rrfScores.get(p) || 0) * 1e4) / 1e4,
9855
- in_fts5: fts5Map.has(p),
9856
- in_semantic: semanticMap.has(p),
9857
- in_entity: entityMap.has(p)
9858
- }));
9859
- merged.sort((a, b) => b.rrf_score - a.rrf_score);
9913
+ const merged = Array.from(allPaths).map((p) => {
9914
+ const title = fts5Map.get(p)?.title || semanticMap.get(p)?.title || entityMap.get(p)?.name || p.replace(/\.md$/, "").split("/").pop() || p;
9915
+ const snippet = fts5Map.get(p)?.snippet;
9916
+ const rrfScore = rrfScores.get(p) || 0;
9917
+ return { rrfScore, ...enrichResult({ path: p, title, snippet }, index, getStateDb()) };
9918
+ });
9919
+ merged.sort((a, b) => b.rrfScore - a.rrfScore);
9920
+ const results = merged.slice(0, limit).map(({ rrfScore, ...rest }) => rest);
9860
9921
  return { content: [{ type: "text", text: JSON.stringify({
9861
9922
  scope,
9862
9923
  method: "hybrid",
9863
9924
  query,
9864
9925
  total_results: Math.min(merged.length, limit),
9865
- results: merged.slice(0, limit)
9926
+ results
9866
9927
  }, null, 2) }] };
9867
9928
  } catch (err) {
9868
9929
  console.error("[Semantic] Hybrid search failed, falling back to FTS5:", err instanceof Error ? err.message : err);
@@ -9871,11 +9932,10 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
9871
9932
  if (entityResults.length > 0) {
9872
9933
  const normalizePath2 = (p) => p.replace(/\\/g, "/").replace(/\/+/g, "/");
9873
9934
  const fts5Map = new Map(fts5Results.map((r) => [normalizePath2(r.path), r]));
9874
- const entityNormMap = new Map(entityResults.map((r) => [normalizePath2(r.path), r]));
9875
9935
  const entityRanked = entityResults.filter((r) => !fts5Map.has(normalizePath2(r.path)));
9876
9936
  const merged = [
9877
- ...fts5Results.map((r) => ({ path: r.path, title: r.title, snippet: r.snippet, in_entity: entityNormMap.has(normalizePath2(r.path)) })),
9878
- ...entityRanked.map((r) => ({ path: r.path, title: r.name, snippet: void 0, in_entity: true }))
9937
+ ...fts5Results.map((r) => enrichResult({ path: r.path, title: r.title, snippet: r.snippet }, index, getStateDb())),
9938
+ ...entityRanked.map((r) => enrichResult({ path: r.path, title: r.name }, index, getStateDb()))
9879
9939
  ];
9880
9940
  return { content: [{ type: "text", text: JSON.stringify({
9881
9941
  scope: "content",
@@ -9885,12 +9945,13 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
9885
9945
  results: merged.slice(0, limit)
9886
9946
  }, null, 2) }] };
9887
9947
  }
9948
+ const enrichedFts5 = fts5Results.map((r) => enrichResult({ path: r.path, title: r.title, snippet: r.snippet }, index, getStateDb()));
9888
9949
  return { content: [{ type: "text", text: JSON.stringify({
9889
9950
  scope: "content",
9890
9951
  method: "fts5",
9891
9952
  query,
9892
- total_results: fts5Results.length,
9893
- results: fts5Results
9953
+ total_results: enrichedFts5.length,
9954
+ results: enrichedFts5
9894
9955
  }, null, 2) }] };
9895
9956
  }
9896
9957
  return { content: [{ type: "text", text: JSON.stringify({ error: "Invalid scope" }, null, 2) }] };
@@ -10260,104 +10321,6 @@ function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfi
10260
10321
  };
10261
10322
  }
10262
10323
  );
10263
- const GetNoteMetadataOutputSchema = {
10264
- path: z5.string().describe("Path to the note"),
10265
- title: z5.string().describe("Note title"),
10266
- exists: z5.boolean().describe("Whether the note exists"),
10267
- frontmatter: z5.record(z5.unknown()).describe("Frontmatter properties"),
10268
- tags: z5.array(z5.string()).describe("Tags on this note"),
10269
- aliases: z5.array(z5.string()).describe("Aliases for this note"),
10270
- outlink_count: z5.number().describe("Number of outgoing links"),
10271
- backlink_count: z5.number().describe("Number of incoming links"),
10272
- word_count: z5.number().optional().describe("Approximate word count"),
10273
- created: z5.string().optional().describe("Created date (ISO format)"),
10274
- modified: z5.string().describe("Last modified date (ISO format)")
10275
- };
10276
- server2.registerTool(
10277
- "get_note_metadata",
10278
- {
10279
- title: "Get Note Metadata",
10280
- description: "Get metadata about a note (frontmatter, tags, link counts) without reading full content. Useful for quick analysis.",
10281
- inputSchema: {
10282
- path: z5.string().describe("Path to the note"),
10283
- include_word_count: z5.boolean().default(false).describe("Count words (requires reading file)")
10284
- },
10285
- outputSchema: GetNoteMetadataOutputSchema
10286
- },
10287
- async ({
10288
- path: notePath,
10289
- include_word_count
10290
- }) => {
10291
- requireIndex();
10292
- const index = getIndex();
10293
- const vaultPath2 = getVaultPath();
10294
- let resolvedPath = notePath;
10295
- if (!notePath.endsWith(".md")) {
10296
- const resolved = index.entities.get(notePath.toLowerCase());
10297
- if (resolved) {
10298
- resolvedPath = resolved;
10299
- } else {
10300
- resolvedPath = notePath + ".md";
10301
- }
10302
- }
10303
- const note = index.notes.get(resolvedPath);
10304
- if (!note) {
10305
- const output2 = {
10306
- path: resolvedPath,
10307
- title: resolvedPath.replace(/\.md$/, "").split("/").pop() || "",
10308
- exists: false,
10309
- frontmatter: {},
10310
- tags: [],
10311
- aliases: [],
10312
- outlink_count: 0,
10313
- backlink_count: 0,
10314
- modified: (/* @__PURE__ */ new Date()).toISOString()
10315
- };
10316
- return {
10317
- content: [
10318
- {
10319
- type: "text",
10320
- text: JSON.stringify(output2, null, 2)
10321
- }
10322
- ],
10323
- structuredContent: output2
10324
- };
10325
- }
10326
- const normalizedPath = resolvedPath.toLowerCase().replace(/\.md$/, "");
10327
- const backlinks = index.backlinks.get(normalizedPath) || [];
10328
- let wordCount;
10329
- if (include_word_count) {
10330
- try {
10331
- const fullPath = path14.join(vaultPath2, resolvedPath);
10332
- const content = await fs11.promises.readFile(fullPath, "utf-8");
10333
- wordCount = content.split(/\s+/).filter((w) => w.length > 0).length;
10334
- } catch {
10335
- }
10336
- }
10337
- const output = {
10338
- path: note.path,
10339
- title: note.title,
10340
- exists: true,
10341
- frontmatter: note.frontmatter,
10342
- tags: note.tags,
10343
- aliases: note.aliases,
10344
- outlink_count: note.outlinks.length,
10345
- backlink_count: backlinks.length,
10346
- word_count: wordCount,
10347
- created: note.created?.toISOString(),
10348
- modified: note.modified.toISOString()
10349
- };
10350
- return {
10351
- content: [
10352
- {
10353
- type: "text",
10354
- text: JSON.stringify(output, null, 2)
10355
- }
10356
- ],
10357
- structuredContent: output
10358
- };
10359
- }
10360
- );
10361
10324
  const GetFolderStructureOutputSchema = {
10362
10325
  folder_count: z5.number().describe("Total number of folders"),
10363
10326
  folders: z5.array(
@@ -10561,7 +10524,7 @@ import { z as z6 } from "zod";
10561
10524
  import * as fs12 from "fs";
10562
10525
  import * as path15 from "path";
10563
10526
  var HEADING_REGEX2 = /^(#{1,6})\s+(.+)$/;
10564
- function extractHeadings(content) {
10527
+ function extractHeadings2(content) {
10565
10528
  const lines = content.split("\n");
10566
10529
  const headings = [];
10567
10530
  let inCodeBlock = false;
@@ -10621,7 +10584,7 @@ async function getNoteStructure(index, notePath, vaultPath2) {
10621
10584
  return null;
10622
10585
  }
10623
10586
  const lines = content.split("\n");
10624
- const headings = extractHeadings(content);
10587
+ const headings = extractHeadings2(content);
10625
10588
  const sections = buildSections(headings, lines.length);
10626
10589
  const contentWithoutCode = content.replace(/```[\s\S]*?```/g, "");
10627
10590
  const words = contentWithoutCode.split(/\s+/).filter((w) => w.length > 0);
@@ -10644,7 +10607,7 @@ async function getSectionContent(index, notePath, headingText, vaultPath2, inclu
10644
10607
  return null;
10645
10608
  }
10646
10609
  const lines = content.split("\n");
10647
- const headings = extractHeadings(content);
10610
+ const headings = extractHeadings2(content);
10648
10611
  const targetHeading = headings.find(
10649
10612
  (h) => h.text.toLowerCase() === headingText.toLowerCase()
10650
10613
  );
@@ -10685,7 +10648,7 @@ async function findSections(index, headingPattern, vaultPath2, folder) {
10685
10648
  } catch {
10686
10649
  continue;
10687
10650
  }
10688
- const headings = extractHeadings(content);
10651
+ const headings = extractHeadings2(content);
10689
10652
  for (const heading of headings) {
10690
10653
  if (regex.test(heading.text)) {
10691
10654
  results.push({
@@ -10701,12 +10664,13 @@ async function findSections(index, headingPattern, vaultPath2, folder) {
10701
10664
  }
10702
10665
 
10703
10666
  // src/tools/read/primitives.ts
10704
- function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = () => ({})) {
10667
+ import { getEntityByName as getEntityByName3 } from "@velvetmonkey/vault-core";
10668
+ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = () => ({}), getStateDb = () => null) {
10705
10669
  server2.registerTool(
10706
10670
  "get_note_structure",
10707
10671
  {
10708
10672
  title: "Get Note Structure",
10709
- description: "Get the heading structure and sections of a note. Returns headings, sections hierarchy, word count, and line count.",
10673
+ description: "Read the structure of a specific note. Use after search identifies a note you need more detail on. Returns headings, frontmatter, tags, word count. Set include_content: true to get the full markdown.",
10710
10674
  inputSchema: {
10711
10675
  path: z6.string().describe("Path to the note"),
10712
10676
  include_content: z6.boolean().default(false).describe("Include the text content under each top-level section")
@@ -10729,8 +10693,31 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
10729
10693
  }
10730
10694
  }
10731
10695
  }
10696
+ const note = index.notes.get(path33);
10697
+ const enriched = { ...result };
10698
+ if (note) {
10699
+ enriched.frontmatter = note.frontmatter;
10700
+ enriched.tags = note.tags;
10701
+ enriched.aliases = note.aliases;
10702
+ const normalizedPath = path33.toLowerCase().replace(/\.md$/, "");
10703
+ const backlinks = index.backlinks.get(normalizedPath) || [];
10704
+ enriched.backlink_count = backlinks.length;
10705
+ enriched.outlink_count = note.outlinks.length;
10706
+ }
10707
+ const stateDb2 = getStateDb();
10708
+ if (stateDb2 && note) {
10709
+ try {
10710
+ const entity = getEntityByName3(stateDb2, note.title);
10711
+ if (entity) {
10712
+ enriched.category = entity.category;
10713
+ enriched.hub_score = entity.hubScore;
10714
+ if (entity.description) enriched.description = entity.description;
10715
+ }
10716
+ } catch {
10717
+ }
10718
+ }
10732
10719
  return {
10733
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
10720
+ content: [{ type: "text", text: JSON.stringify(enriched, null, 2) }]
10734
10721
  };
10735
10722
  }
10736
10723
  );
@@ -13270,7 +13257,7 @@ function ensureSectionExists(content, section, notePath) {
13270
13257
  if (boundary) {
13271
13258
  return { boundary };
13272
13259
  }
13273
- const headings = extractHeadings2(content);
13260
+ const headings = extractHeadings3(content);
13274
13261
  let message;
13275
13262
  if (headings.length === 0) {
13276
13263
  message = `Section '${section}' not found. This file has no headings. Add section structure (## Heading) to enable section-scoped mutations.`;
@@ -19333,59 +19320,58 @@ var watcherStatus = null;
19333
19320
  function getWatcherStatus() {
19334
19321
  return watcherStatus;
19335
19322
  }
19336
- var PRESETS = {
19337
- // Presets
19338
- minimal: ["search", "structure", "append", "frontmatter", "notes"],
19339
- writer: ["search", "structure", "append", "frontmatter", "notes", "tasks"],
19340
- agent: ["search", "structure", "append", "frontmatter", "notes", "memory"],
19341
- researcher: ["search", "structure", "backlinks", "hubs", "paths"],
19342
- full: [
19343
- "search",
19344
- "backlinks",
19345
- "orphans",
19346
- "hubs",
19347
- "paths",
19348
- "schema",
19349
- "structure",
19350
- "tasks",
19351
- "health",
19352
- "wikilinks",
19353
- "append",
19354
- "frontmatter",
19355
- "notes",
19356
- "note-ops",
19357
- "git",
19358
- "policy",
19359
- "memory"
19360
- ],
19361
- // Composable bundles
19362
- graph: ["backlinks", "orphans", "hubs", "paths"],
19363
- analysis: ["schema", "wikilinks"],
19364
- tasks: ["tasks"],
19365
- health: ["health"],
19366
- ops: ["git", "policy"],
19367
- "note-ops": ["note-ops"]
19368
- };
19369
19323
  var ALL_CATEGORIES = [
19370
- "backlinks",
19371
- "orphans",
19372
- "hubs",
19373
- "paths",
19374
19324
  "search",
19325
+ "read",
19326
+ "write",
19327
+ "graph",
19375
19328
  "schema",
19376
- "structure",
19377
- "tasks",
19378
- "health",
19379
19329
  "wikilinks",
19380
- "append",
19381
- "frontmatter",
19382
- "notes",
19330
+ "corrections",
19331
+ "tasks",
19332
+ "memory",
19383
19333
  "note-ops",
19384
- "git",
19385
- "policy",
19386
- "memory"
19334
+ "diagnostics",
19335
+ "automation"
19387
19336
  ];
19388
- var DEFAULT_PRESET = "full";
19337
+ var PRESETS = {
19338
+ // Presets
19339
+ default: ["search", "read", "write", "tasks"],
19340
+ agent: ["search", "read", "write", "memory"],
19341
+ full: ALL_CATEGORIES,
19342
+ // Composable bundles (one per category)
19343
+ graph: ["graph"],
19344
+ schema: ["schema"],
19345
+ wikilinks: ["wikilinks"],
19346
+ corrections: ["corrections"],
19347
+ tasks: ["tasks"],
19348
+ memory: ["memory"],
19349
+ "note-ops": ["note-ops"],
19350
+ diagnostics: ["diagnostics"],
19351
+ automation: ["automation"]
19352
+ };
19353
+ var DEFAULT_PRESET = "default";
19354
+ var DEPRECATED_ALIASES = {
19355
+ minimal: "default",
19356
+ writer: "default",
19357
+ // writer was default+tasks, now default includes tasks
19358
+ researcher: "default",
19359
+ // use default,graph for graph exploration
19360
+ backlinks: "graph",
19361
+ // get_backlinks moved to graph
19362
+ structure: "read",
19363
+ append: "write",
19364
+ frontmatter: "write",
19365
+ notes: "write",
19366
+ orphans: "graph",
19367
+ hubs: "graph",
19368
+ paths: "graph",
19369
+ health: "diagnostics",
19370
+ analysis: "wikilinks",
19371
+ git: "automation",
19372
+ ops: "automation",
19373
+ policy: "automation"
19374
+ };
19389
19375
  function parseEnabledCategories() {
19390
19376
  const envValue = (process.env.FLYWHEEL_TOOLS ?? process.env.FLYWHEEL_PRESET)?.trim();
19391
19377
  if (!envValue) {
@@ -19395,13 +19381,25 @@ function parseEnabledCategories() {
19395
19381
  if (PRESETS[lowerValue]) {
19396
19382
  return new Set(PRESETS[lowerValue]);
19397
19383
  }
19384
+ if (DEPRECATED_ALIASES[lowerValue]) {
19385
+ const resolved = DEPRECATED_ALIASES[lowerValue];
19386
+ serverLog("server", `Preset "${lowerValue}" is deprecated \u2014 use "${resolved}" instead`, "warn");
19387
+ if (PRESETS[resolved]) {
19388
+ return new Set(PRESETS[resolved]);
19389
+ }
19390
+ return /* @__PURE__ */ new Set([resolved]);
19391
+ }
19398
19392
  const categories = /* @__PURE__ */ new Set();
19399
19393
  for (const item of envValue.split(",")) {
19400
- const category = item.trim().toLowerCase();
19401
- if (ALL_CATEGORIES.includes(category)) {
19402
- categories.add(category);
19403
- } else if (PRESETS[category]) {
19404
- for (const c of PRESETS[category]) {
19394
+ const raw = item.trim().toLowerCase();
19395
+ const resolved = DEPRECATED_ALIASES[raw] ?? raw;
19396
+ if (resolved !== raw) {
19397
+ serverLog("server", `Category "${raw}" is deprecated \u2014 use "${resolved}" instead`, "warn");
19398
+ }
19399
+ if (ALL_CATEGORIES.includes(resolved)) {
19400
+ categories.add(resolved);
19401
+ } else if (PRESETS[resolved]) {
19402
+ for (const c of PRESETS[resolved]) {
19405
19403
  categories.add(c);
19406
19404
  }
19407
19405
  } else {
@@ -19416,88 +19414,146 @@ function parseEnabledCategories() {
19416
19414
  }
19417
19415
  var enabledCategories = parseEnabledCategories();
19418
19416
  var TOOL_CATEGORY = {
19419
- // health (includes periodic detection in output)
19420
- health_check: "health",
19421
- get_vault_stats: "health",
19422
- get_folder_structure: "health",
19423
- refresh_index: "health",
19424
- // absorbed rebuild_search_index
19425
- get_all_entities: "health",
19426
- list_entities: "hubs",
19427
- get_unlinked_mentions: "health",
19428
- // search (unified: metadata + content + entities)
19417
+ // search (3 tools)
19429
19418
  search: "search",
19430
19419
  init_semantic: "search",
19431
- // backlinks
19432
- get_backlinks: "backlinks",
19433
- get_forward_links: "backlinks",
19434
- // orphans (graph_analysis covers orphans, dead_ends, sources, hubs, stale)
19435
- graph_analysis: "orphans",
19436
- get_connection_strength: "hubs",
19437
- // paths
19438
- get_link_path: "paths",
19439
- get_common_neighbors: "paths",
19440
- // schema (vault_schema + note_intelligence cover all schema tools)
19420
+ find_similar: "search",
19421
+ // read (3 tools) — note reading
19422
+ get_note_structure: "read",
19423
+ get_section_content: "read",
19424
+ find_sections: "read",
19425
+ // write (5 tools) — content mutations + frontmatter + note creation
19426
+ vault_add_to_section: "write",
19427
+ vault_remove_from_section: "write",
19428
+ vault_replace_in_section: "write",
19429
+ vault_update_frontmatter: "write",
19430
+ vault_create_note: "write",
19431
+ // graph (9 tools) — structural analysis + link detail
19432
+ graph_analysis: "graph",
19433
+ get_backlinks: "graph",
19434
+ get_forward_links: "graph",
19435
+ get_connection_strength: "graph",
19436
+ list_entities: "graph",
19437
+ get_link_path: "graph",
19438
+ get_common_neighbors: "graph",
19439
+ get_weighted_links: "graph",
19440
+ get_strong_connections: "graph",
19441
+ // schema (5 tools) — schema intelligence + migrations
19441
19442
  vault_schema: "schema",
19442
19443
  note_intelligence: "schema",
19443
- // structure (absorbed get_headings + vault_list_sections)
19444
- get_note_structure: "structure",
19445
- get_section_content: "structure",
19446
- find_sections: "structure",
19447
- get_note_metadata: "structure",
19448
- // tasks (unified: all task queries + write)
19444
+ rename_field: "schema",
19445
+ migrate_field_values: "schema",
19446
+ rename_tag: "schema",
19447
+ // wikilinks (7 tools) — suggestions, validation, discovery
19448
+ suggest_wikilinks: "wikilinks",
19449
+ validate_links: "wikilinks",
19450
+ wikilink_feedback: "wikilinks",
19451
+ discover_stub_candidates: "wikilinks",
19452
+ discover_cooccurrence_gaps: "wikilinks",
19453
+ suggest_entity_aliases: "wikilinks",
19454
+ unlinked_mentions_report: "wikilinks",
19455
+ // corrections (4 tools)
19456
+ vault_record_correction: "corrections",
19457
+ vault_list_corrections: "corrections",
19458
+ vault_resolve_correction: "corrections",
19459
+ absorb_as_alias: "corrections",
19460
+ // tasks (3 tools)
19449
19461
  tasks: "tasks",
19450
19462
  vault_toggle_task: "tasks",
19451
19463
  vault_add_task: "tasks",
19452
- // wikilinks
19453
- suggest_wikilinks: "wikilinks",
19454
- validate_links: "wikilinks",
19455
- // append (content mutations)
19456
- vault_add_to_section: "append",
19457
- vault_remove_from_section: "append",
19458
- vault_replace_in_section: "append",
19459
- // frontmatter (absorbed vault_add_frontmatter_field via only_if_missing)
19460
- vault_update_frontmatter: "frontmatter",
19461
- // notes (create only)
19462
- vault_create_note: "notes",
19463
- // note-ops (file management)
19464
+ // memory (3 tools) — agent working memory
19465
+ memory: "memory",
19466
+ recall: "memory",
19467
+ brief: "memory",
19468
+ // note-ops (4 tools) — file management
19464
19469
  vault_delete_note: "note-ops",
19465
19470
  vault_move_note: "note-ops",
19466
19471
  vault_rename_note: "note-ops",
19467
- // git
19468
- vault_undo_last_mutation: "git",
19469
- // policy
19470
- policy: "policy",
19471
- // schema (migrations + tag rename)
19472
- rename_field: "schema",
19473
- migrate_field_values: "schema",
19474
- rename_tag: "schema",
19475
- // health (growth metrics)
19476
- vault_growth: "health",
19477
- // wikilinks (feedback)
19478
- wikilink_feedback: "wikilinks",
19479
- // health (activity tracking)
19480
- vault_activity: "health",
19481
- // schema (content similarity)
19482
- find_similar: "schema",
19483
- // health (config management)
19484
- flywheel_config: "health",
19485
- // health (server activity log)
19486
- server_log: "health",
19487
- // health (merge suggestions)
19488
- suggest_entity_merges: "health",
19489
- dismiss_merge_suggestion: "health",
19490
- // note-ops (entity merge)
19491
19472
  merge_entities: "note-ops",
19492
- // memory (agent working memory)
19493
- memory: "memory",
19494
- recall: "memory",
19495
- brief: "memory"
19473
+ // diagnostics (13 tools) — vault health, stats, config, activity
19474
+ health_check: "diagnostics",
19475
+ get_vault_stats: "diagnostics",
19476
+ get_folder_structure: "diagnostics",
19477
+ refresh_index: "diagnostics",
19478
+ get_all_entities: "diagnostics",
19479
+ get_unlinked_mentions: "diagnostics",
19480
+ vault_growth: "diagnostics",
19481
+ vault_activity: "diagnostics",
19482
+ flywheel_config: "diagnostics",
19483
+ server_log: "diagnostics",
19484
+ suggest_entity_merges: "diagnostics",
19485
+ dismiss_merge_suggestion: "diagnostics",
19486
+ vault_init: "diagnostics",
19487
+ // automation (2 tools) — git undo + policy engine
19488
+ vault_undo_last_mutation: "automation",
19489
+ policy: "automation"
19496
19490
  };
19497
- var server = new McpServer({
19498
- name: "flywheel-memory",
19499
- version: pkg.version
19500
- });
19491
+ function generateInstructions(categories) {
19492
+ const parts = [];
19493
+ parts.push(`Flywheel provides tools to search, read, and write an Obsidian vault's knowledge graph.
19494
+
19495
+ Tool selection:
19496
+ 1. "search" is the primary tool. Each result includes: frontmatter, tags, aliases,
19497
+ backlinks (with line numbers), outlinks (with line numbers and existence check),
19498
+ headings, content snippet or preview, entity category, hub score, and timestamps.
19499
+ This is usually enough to answer without reading any files.
19500
+ 2. Escalate to "get_note_structure" only when you need the full markdown content
19501
+ or word count. Use "get_section_content" to read one section by heading name.
19502
+ 3. Use vault write tools instead of raw file writes \u2014 they auto-link entities
19503
+ and commit changes.`);
19504
+ if (categories.has("read")) {
19505
+ parts.push(`
19506
+ ## Read
19507
+
19508
+ Escalation: "search" (enriched metadata + content preview) \u2192 "get_note_structure"
19509
+ (full content + word count) \u2192 "get_section_content" (single section).
19510
+ "find_sections" finds headings across the vault by pattern.`);
19511
+ }
19512
+ if (categories.has("write")) {
19513
+ parts.push(`
19514
+ ## Write
19515
+
19516
+ Write to existing notes with "vault_add_to_section". Create new notes with "vault_create_note". Update metadata with "vault_update_frontmatter". All writes auto-link entities \u2014 no manual [[wikilinks]] needed.`);
19517
+ }
19518
+ if (categories.has("memory")) {
19519
+ parts.push(`
19520
+ ## Memory
19521
+
19522
+ Session workflow: call "brief" at conversation start for vault context (recent sessions, active entities, stored memories). Use "recall" before answering questions \u2014 it searches entities, notes, and memories with graph-boosted ranking. Use "memory" to store observations that should persist across sessions.`);
19523
+ }
19524
+ if (categories.has("graph")) {
19525
+ parts.push(`
19526
+ ## Graph
19527
+
19528
+ Use "get_backlinks" for per-backlink surrounding text (reads source files).
19529
+ Use "get_forward_links" for resolved file paths and alias text.
19530
+ Use "graph_analysis" for structural queries (hubs, orphans, dead ends).
19531
+ Use "get_connection_strength" to measure link strength between notes.
19532
+ Use "get_link_path" to find shortest paths.`);
19533
+ }
19534
+ if (categories.has("tasks")) {
19535
+ parts.push(`
19536
+ ## Tasks
19537
+
19538
+ Use "tasks" to query tasks across the vault (filter by status, due date, path). Use "vault_add_task" to create tasks and "vault_toggle_task" to complete them.`);
19539
+ }
19540
+ if (categories.has("schema")) {
19541
+ parts.push(`
19542
+ ## Schema
19543
+
19544
+ Use "vault_schema" before bulk operations to understand field conventions, inconsistencies, and note types. Use "note_intelligence" for per-note analysis.`);
19545
+ }
19546
+ return parts.join("\n");
19547
+ }
19548
+ var server = new McpServer(
19549
+ {
19550
+ name: "flywheel-memory",
19551
+ version: pkg.version
19552
+ },
19553
+ {
19554
+ instructions: generateInstructions(enabledCategories)
19555
+ }
19556
+ );
19501
19557
  var _registeredCount = 0;
19502
19558
  var _skippedCount = 0;
19503
19559
  function gateByCategory(name) {
@@ -19587,7 +19643,7 @@ registerSystemTools(
19587
19643
  registerGraphTools(server, () => vaultIndex, () => vaultPath, () => stateDb);
19588
19644
  registerWikilinkTools(server, () => vaultIndex, () => vaultPath);
19589
19645
  registerQueryTools(server, () => vaultIndex, () => vaultPath, () => stateDb);
19590
- registerPrimitiveTools(server, () => vaultIndex, () => vaultPath, () => flywheelConfig);
19646
+ registerPrimitiveTools(server, () => vaultIndex, () => vaultPath, () => flywheelConfig, () => stateDb);
19591
19647
  registerGraphAnalysisTools(server, () => vaultIndex, () => vaultPath, () => stateDb, () => flywheelConfig);
19592
19648
  registerVaultSchemaTools(server, () => vaultIndex, () => vaultPath);
19593
19649
  registerNoteIntelligenceTools(server, () => vaultIndex, () => vaultPath, () => flywheelConfig);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.0.77",
3
+ "version": "2.0.79",
4
4
  "description": "MCP server that gives Claude full read/write access to your Obsidian vault. Select from 51 tools for search, backlinks, graph queries, mutations, agent memory, 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.77",
55
+ "@velvetmonkey/vault-core": "^2.0.78",
56
56
  "better-sqlite3": "^11.0.0",
57
57
  "chokidar": "^4.0.0",
58
58
  "gray-matter": "^4.0.3",