@velvetmonkey/flywheel-memory 2.0.28 → 2.0.29

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 +141 -23
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -476,7 +476,10 @@ async function readVaultFile(vaultPath2, notePath) {
476
476
  throw new Error("Invalid path: path traversal not allowed");
477
477
  }
478
478
  const fullPath = path18.join(vaultPath2, notePath);
479
- const rawContent = await fs18.readFile(fullPath, "utf-8");
479
+ const [rawContent, stat3] = await Promise.all([
480
+ fs18.readFile(fullPath, "utf-8"),
481
+ fs18.stat(fullPath)
482
+ ]);
480
483
  const lineEnding = detectLineEnding(rawContent);
481
484
  const normalizedContent = normalizeLineEndings(rawContent);
482
485
  const parsed = matter5(normalizedContent);
@@ -485,7 +488,8 @@ async function readVaultFile(vaultPath2, notePath) {
485
488
  content: parsed.content,
486
489
  frontmatter,
487
490
  rawContent,
488
- lineEnding
491
+ lineEnding,
492
+ mtimeMs: stat3.mtimeMs
489
493
  };
490
494
  }
491
495
  function deepCloneFrontmatter(obj) {
@@ -7402,6 +7406,7 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
7402
7406
  note_count: z3.coerce.number().describe("Number of notes in the index"),
7403
7407
  entity_count: z3.coerce.number().describe("Number of linkable entities (titles + aliases)"),
7404
7408
  tag_count: z3.coerce.number().describe("Number of unique tags"),
7409
+ link_count: z3.coerce.number().describe("Total number of outgoing wikilinks"),
7405
7410
  periodic_notes: z3.array(PeriodicNoteInfoSchema).optional().describe("Detected periodic note conventions"),
7406
7411
  config: z3.record(z3.unknown()).optional().describe("Current flywheel config (paths, templates, etc.)"),
7407
7412
  last_rebuild: z3.object({
@@ -7455,6 +7460,10 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
7455
7460
  const noteCount = indexBuilt ? index.notes.size : 0;
7456
7461
  const entityCount = indexBuilt ? index.entities.size : 0;
7457
7462
  const tagCount = indexBuilt ? index.tags.size : 0;
7463
+ let linkCount = 0;
7464
+ if (indexBuilt) {
7465
+ for (const note of index.notes.values()) linkCount += note.outlinks.length;
7466
+ }
7458
7467
  if (indexBuilt && noteCount === 0 && vaultAccessible) {
7459
7468
  recommendations.push("No notes found in vault. Is PROJECT_PATH pointing to a markdown vault?");
7460
7469
  }
@@ -7515,6 +7524,7 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
7515
7524
  note_count: noteCount,
7516
7525
  entity_count: entityCount,
7517
7526
  tag_count: tagCount,
7527
+ link_count: linkCount,
7518
7528
  periodic_notes: periodicNotes && periodicNotes.length > 0 ? periodicNotes : void 0,
7519
7529
  config: configInfo,
7520
7530
  last_rebuild: lastRebuild,
@@ -7999,6 +8009,64 @@ import * as fs11 from "fs";
7999
8009
  import * as path12 from "path";
8000
8010
  import { z as z5 } from "zod";
8001
8011
  import { scanVaultEntities as scanVaultEntities2, getEntityIndexFromDb as getEntityIndexFromDb2 } from "@velvetmonkey/vault-core";
8012
+
8013
+ // src/core/read/aliasSuggestions.ts
8014
+ function generateAliasCandidates(entityName, existingAliases) {
8015
+ const existing = new Set(existingAliases.map((a) => a.toLowerCase()));
8016
+ const candidates = [];
8017
+ const words = entityName.split(/[\s-]+/).filter((w) => w.length > 0);
8018
+ if (words.length >= 2) {
8019
+ const acronym = words.map((w) => w[0]).join("").toUpperCase();
8020
+ if (acronym.length >= 2 && acronym.length <= 6 && !existing.has(acronym.toLowerCase())) {
8021
+ candidates.push({ candidate: acronym, type: "acronym" });
8022
+ }
8023
+ if (words.length >= 3) {
8024
+ const short = words[0];
8025
+ if (short.length >= 3 && !existing.has(short.toLowerCase())) {
8026
+ candidates.push({ candidate: short, type: "short_form" });
8027
+ }
8028
+ }
8029
+ }
8030
+ return candidates;
8031
+ }
8032
+ function suggestEntityAliases(stateDb2, folder) {
8033
+ const db4 = stateDb2.db;
8034
+ const entities = folder ? db4.prepare(
8035
+ "SELECT name, path, aliases_json FROM entities WHERE path LIKE ? || '/%'"
8036
+ ).all(folder) : db4.prepare("SELECT name, path, aliases_json FROM entities").all();
8037
+ const allEntityNames = new Set(
8038
+ db4.prepare("SELECT name_lower FROM entities").all().map((r) => r.name_lower)
8039
+ );
8040
+ const suggestions = [];
8041
+ const countStmt = db4.prepare(
8042
+ "SELECT COUNT(*) as cnt FROM notes_fts WHERE content MATCH ?"
8043
+ );
8044
+ for (const row of entities) {
8045
+ const aliases = row.aliases_json ? JSON.parse(row.aliases_json) : [];
8046
+ const candidates = generateAliasCandidates(row.name, aliases);
8047
+ for (const { candidate, type } of candidates) {
8048
+ if (allEntityNames.has(candidate.toLowerCase())) continue;
8049
+ let mentions = 0;
8050
+ try {
8051
+ const result = countStmt.get(`"${candidate}"`);
8052
+ mentions = result?.cnt ?? 0;
8053
+ } catch {
8054
+ }
8055
+ suggestions.push({
8056
+ entity: row.name,
8057
+ entity_path: row.path,
8058
+ current_aliases: aliases,
8059
+ candidate,
8060
+ type,
8061
+ mentions
8062
+ });
8063
+ }
8064
+ }
8065
+ suggestions.sort((a, b) => b.mentions - a.mentions || a.entity.localeCompare(b.entity));
8066
+ return suggestions;
8067
+ }
8068
+
8069
+ // src/tools/read/system.ts
8002
8070
  function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfig, getStateDb) {
8003
8071
  const RefreshIndexOutputSchema = {
8004
8072
  success: z5.boolean().describe("Whether the refresh succeeded"),
@@ -8485,6 +8553,35 @@ function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfi
8485
8553
  };
8486
8554
  }
8487
8555
  );
8556
+ server2.registerTool(
8557
+ "suggest_entity_aliases",
8558
+ {
8559
+ title: "Suggest Entity Aliases",
8560
+ description: "Generate alias suggestions for entities in a folder based on acronyms and short forms, validated against vault content.",
8561
+ inputSchema: {
8562
+ folder: z5.string().optional().describe("Folder path to scope suggestions to"),
8563
+ limit: z5.number().default(20).describe("Max suggestions to return")
8564
+ }
8565
+ },
8566
+ async ({
8567
+ folder,
8568
+ limit: requestedLimit
8569
+ }) => {
8570
+ const stateDb2 = getStateDb?.();
8571
+ if (!stateDb2) {
8572
+ return {
8573
+ content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available" }) }]
8574
+ };
8575
+ }
8576
+ const suggestions = suggestEntityAliases(stateDb2, folder || void 0);
8577
+ const limit = Math.min(requestedLimit ?? 20, 50);
8578
+ const limited = suggestions.slice(0, limit);
8579
+ const output = { suggestion_count: limited.length, suggestions: limited };
8580
+ return {
8581
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }]
8582
+ };
8583
+ }
8584
+ );
8488
8585
  }
8489
8586
 
8490
8587
  // src/tools/read/primitives.ts
@@ -8799,6 +8896,8 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
8799
8896
  content: [{ type: "text", text: JSON.stringify({
8800
8897
  total_count: result2.total,
8801
8898
  open_count: result2.open_count,
8899
+ completed_count: result2.completed_count,
8900
+ cancelled_count: result2.cancelled_count,
8802
8901
  returned_count: result2.tasks.length,
8803
8902
  tasks: result2.tasks
8804
8903
  }, null, 2) }]
@@ -8831,6 +8930,8 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
8831
8930
  content: [{ type: "text", text: JSON.stringify({
8832
8931
  total_count: result.total,
8833
8932
  open_count: result.open_count,
8933
+ completed_count: result.completed_count,
8934
+ cancelled_count: result.cancelled_count,
8834
8935
  returned_count: paged.length,
8835
8936
  tasks: paged
8836
8937
  }, null, 2) }]
@@ -11141,41 +11242,58 @@ async function withVaultFile(options, operation) {
11141
11242
  if (existsError) {
11142
11243
  return formatMcpResult(existsError);
11143
11244
  }
11144
- const { content, frontmatter, lineEnding } = await readVaultFile(vaultPath2, notePath);
11145
- const writeStateDb = getWriteStateDb();
11146
- if (writeStateDb) {
11147
- processImplicitFeedback(writeStateDb, notePath, content);
11245
+ const runMutation = async () => {
11246
+ const { content, frontmatter: frontmatter2, lineEnding: lineEnding2, mtimeMs } = await readVaultFile(vaultPath2, notePath);
11247
+ const writeStateDb = getWriteStateDb();
11248
+ if (writeStateDb) {
11249
+ processImplicitFeedback(writeStateDb, notePath, content);
11250
+ }
11251
+ let sectionBoundary;
11252
+ if (section) {
11253
+ const sectionResult = ensureSectionExists(content, section, notePath);
11254
+ if ("error" in sectionResult) {
11255
+ return { error: sectionResult.error };
11256
+ }
11257
+ sectionBoundary = sectionResult.boundary;
11258
+ }
11259
+ const ctx = {
11260
+ content,
11261
+ frontmatter: frontmatter2,
11262
+ lineEnding: lineEnding2,
11263
+ sectionBoundary,
11264
+ vaultPath: vaultPath2,
11265
+ notePath
11266
+ };
11267
+ const opResult2 = await operation(ctx);
11268
+ return { opResult: opResult2, frontmatter: frontmatter2, lineEnding: lineEnding2, mtimeMs };
11269
+ };
11270
+ let result = await runMutation();
11271
+ if ("error" in result) {
11272
+ return formatMcpResult(result.error);
11148
11273
  }
11149
- let sectionBoundary;
11150
- if (section) {
11151
- const sectionResult = ensureSectionExists(content, section, notePath);
11152
- if ("error" in sectionResult) {
11153
- return formatMcpResult(sectionResult.error);
11274
+ const fullPath = path19.join(vaultPath2, notePath);
11275
+ const statBefore = await fs19.stat(fullPath);
11276
+ if (statBefore.mtimeMs !== result.mtimeMs) {
11277
+ console.warn(`[withVaultFile] External modification detected on ${notePath}, re-reading and retrying`);
11278
+ result = await runMutation();
11279
+ if ("error" in result) {
11280
+ return formatMcpResult(result.error);
11154
11281
  }
11155
- sectionBoundary = sectionResult.boundary;
11156
11282
  }
11157
- const ctx = {
11158
- content,
11159
- frontmatter,
11160
- lineEnding,
11161
- sectionBoundary,
11162
- vaultPath: vaultPath2,
11163
- notePath
11164
- };
11165
- const opResult = await operation(ctx);
11283
+ const { opResult, frontmatter, lineEnding } = result;
11166
11284
  let finalFrontmatter = opResult.updatedFrontmatter ?? frontmatter;
11167
11285
  if (scoping && (scoping.agent_id || scoping.session_id)) {
11168
11286
  finalFrontmatter = injectMutationMetadata(finalFrontmatter, scoping);
11169
11287
  }
11170
11288
  await writeVaultFile(vaultPath2, notePath, opResult.updatedContent, finalFrontmatter, lineEnding);
11171
11289
  const gitInfo = await handleGitCommit(vaultPath2, notePath, commit, commitPrefix);
11172
- const result = successResult(notePath, opResult.message, gitInfo, {
11290
+ const successRes = successResult(notePath, opResult.message, gitInfo, {
11173
11291
  preview: opResult.preview,
11174
11292
  warnings: opResult.warnings,
11175
11293
  outputIssues: opResult.outputIssues,
11176
11294
  normalizationChanges: opResult.normalizationChanges
11177
11295
  });
11178
- return formatMcpResult(result);
11296
+ return formatMcpResult(successRes);
11179
11297
  } catch (error) {
11180
11298
  const extras = {};
11181
11299
  if (error instanceof DiagnosticError) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.0.28",
3
+ "version": "2.0.29",
4
4
  "description": "MCP server that gives Claude full read/write access to your Obsidian vault. 42 tools for search, backlinks, graph queries, mutations, and hybrid semantic search.",
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.28",
53
+ "@velvetmonkey/vault-core": "^2.0.29",
54
54
  "better-sqlite3": "^11.0.0",
55
55
  "chokidar": "^4.0.0",
56
56
  "gray-matter": "^4.0.3",