@velvetmonkey/flywheel-memory 2.0.28 → 2.0.30

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 +396 -135
  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) {
@@ -2481,7 +2485,8 @@ import {
2481
2485
  } from "@velvetmonkey/vault-core";
2482
2486
  var DEFAULT_CONFIG = {
2483
2487
  exclude_task_tags: [],
2484
- exclude_analysis_tags: []
2488
+ exclude_analysis_tags: [],
2489
+ exclude_entities: []
2485
2490
  };
2486
2491
  function loadConfig(stateDb2) {
2487
2492
  if (stateDb2) {
@@ -5894,36 +5899,38 @@ async function buildFTS5Index(vaultPath2) {
5894
5899
  if (!db2) {
5895
5900
  throw new Error("FTS5 database not initialized. Call setFTS5Database() first.");
5896
5901
  }
5897
- db2.exec("DELETE FROM notes_fts");
5898
5902
  const files = await scanVault(vaultPath2);
5899
5903
  const indexableFiles = files.filter((f) => shouldIndexFile2(f.path));
5904
+ const rows = [];
5905
+ for (const file of indexableFiles) {
5906
+ try {
5907
+ const stats = fs7.statSync(file.absolutePath);
5908
+ if (stats.size > MAX_INDEX_FILE_SIZE) {
5909
+ continue;
5910
+ }
5911
+ const raw = fs7.readFileSync(file.absolutePath, "utf-8");
5912
+ const { frontmatter, body } = splitFrontmatter(raw);
5913
+ const title = file.path.replace(/\.md$/, "").split("/").pop() || file.path;
5914
+ rows.push([file.path, title, frontmatter, body]);
5915
+ } catch (err) {
5916
+ console.error(`[FTS5] Skipping ${file.path}:`, err);
5917
+ }
5918
+ }
5900
5919
  const insert = db2.prepare(
5901
5920
  "INSERT INTO notes_fts (path, title, frontmatter, content) VALUES (?, ?, ?, ?)"
5902
5921
  );
5903
- const insertMany = db2.transaction((filesToIndex) => {
5904
- let indexed2 = 0;
5905
- for (const file of filesToIndex) {
5906
- try {
5907
- const stats = fs7.statSync(file.absolutePath);
5908
- if (stats.size > MAX_INDEX_FILE_SIZE) {
5909
- continue;
5910
- }
5911
- const raw = fs7.readFileSync(file.absolutePath, "utf-8");
5912
- const { frontmatter, body } = splitFrontmatter(raw);
5913
- const title = file.path.replace(/\.md$/, "").split("/").pop() || file.path;
5914
- insert.run(file.path, title, frontmatter, body);
5915
- indexed2++;
5916
- } catch (err) {
5917
- console.error(`[FTS5] Skipping ${file.path}:`, err);
5918
- }
5919
- }
5920
- return indexed2;
5921
- });
5922
- const indexed = insertMany(indexableFiles);
5923
5922
  const now = /* @__PURE__ */ new Date();
5924
- db2.prepare(
5925
- "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)"
5926
- ).run("last_built", now.toISOString());
5923
+ const swapAll = db2.transaction(() => {
5924
+ db2.exec("DELETE FROM notes_fts");
5925
+ for (const row of rows) {
5926
+ insert.run(...row);
5927
+ }
5928
+ db2.prepare(
5929
+ "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)"
5930
+ ).run("last_built", now.toISOString());
5931
+ });
5932
+ swapAll();
5933
+ const indexed = rows.length;
5927
5934
  state = {
5928
5935
  ready: true,
5929
5936
  building: false,
@@ -6183,6 +6190,9 @@ function setTaskCacheDatabase(database) {
6183
6190
  function isTaskCacheReady() {
6184
6191
  return cacheReady && db3 !== null;
6185
6192
  }
6193
+ function isTaskCacheBuilding() {
6194
+ return rebuildInProgress;
6195
+ }
6186
6196
  async function buildTaskCache(vaultPath2, index, excludeTags) {
6187
6197
  if (!db3) {
6188
6198
  throw new Error("Task cache database not initialized. Call setTaskCacheDatabase() first.");
@@ -6191,53 +6201,47 @@ async function buildTaskCache(vaultPath2, index, excludeTags) {
6191
6201
  rebuildInProgress = true;
6192
6202
  const start = Date.now();
6193
6203
  try {
6204
+ const notePaths = [];
6205
+ for (const note of index.notes.values()) {
6206
+ notePaths.push(note.path);
6207
+ }
6208
+ const allRows = [];
6209
+ for (const notePath of notePaths) {
6210
+ const absolutePath = path10.join(vaultPath2, notePath);
6211
+ const tasks = await extractTasksFromNote(notePath, absolutePath);
6212
+ for (const task of tasks) {
6213
+ if (excludeTags?.length && excludeTags.some((t) => task.tags.includes(t))) {
6214
+ continue;
6215
+ }
6216
+ allRows.push([
6217
+ task.path,
6218
+ task.line,
6219
+ task.text,
6220
+ task.status,
6221
+ task.raw,
6222
+ task.context ?? null,
6223
+ task.tags.length > 0 ? JSON.stringify(task.tags) : null,
6224
+ task.due_date ?? null
6225
+ ]);
6226
+ }
6227
+ }
6194
6228
  const insertStmt = db3.prepare(`
6195
6229
  INSERT OR REPLACE INTO tasks (path, line, text, status, raw, context, tags_json, due_date)
6196
6230
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
6197
6231
  `);
6198
- const insertAll = db3.transaction(() => {
6232
+ const swapAll = db3.transaction(() => {
6199
6233
  db3.prepare("DELETE FROM tasks").run();
6200
- let count = 0;
6201
- const promises7 = [];
6202
- const notePaths2 = [];
6203
- for (const note of index.notes.values()) {
6204
- notePaths2.push(note.path);
6234
+ for (const row of allRows) {
6235
+ insertStmt.run(...row);
6205
6236
  }
6206
- return { notePaths: notePaths2, insertStmt };
6237
+ db3.prepare(
6238
+ "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)"
6239
+ ).run("task_cache_built", (/* @__PURE__ */ new Date()).toISOString());
6207
6240
  });
6208
- const { notePaths, insertStmt: stmt } = insertAll();
6209
- let totalTasks = 0;
6210
- for (const notePath of notePaths) {
6211
- const absolutePath = path10.join(vaultPath2, notePath);
6212
- const tasks = await extractTasksFromNote(notePath, absolutePath);
6213
- if (tasks.length > 0) {
6214
- const insertBatch = db3.transaction(() => {
6215
- for (const task of tasks) {
6216
- if (excludeTags?.length && excludeTags.some((t) => task.tags.includes(t))) {
6217
- continue;
6218
- }
6219
- stmt.run(
6220
- task.path,
6221
- task.line,
6222
- task.text,
6223
- task.status,
6224
- task.raw,
6225
- task.context ?? null,
6226
- task.tags.length > 0 ? JSON.stringify(task.tags) : null,
6227
- task.due_date ?? null
6228
- );
6229
- totalTasks++;
6230
- }
6231
- });
6232
- insertBatch();
6233
- }
6234
- }
6235
- db3.prepare(
6236
- "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)"
6237
- ).run("task_cache_built", (/* @__PURE__ */ new Date()).toISOString());
6241
+ swapAll();
6238
6242
  cacheReady = true;
6239
6243
  const duration = Date.now() - start;
6240
- serverLog("tasks", `Task cache built: ${totalTasks} tasks from ${notePaths.length} notes in ${duration}ms`);
6244
+ serverLog("tasks", `Task cache built: ${allRows.length} tasks from ${notePaths.length} notes in ${duration}ms`);
6241
6245
  } finally {
6242
6246
  rebuildInProgress = false;
6243
6247
  }
@@ -6671,10 +6675,20 @@ async function getContext(vaultPath2, sourcePath, line, contextLines = 1) {
6671
6675
  try {
6672
6676
  const fullPath = path11.join(vaultPath2, sourcePath);
6673
6677
  const content = await fs9.promises.readFile(fullPath, "utf-8");
6674
- const lines = content.split("\n");
6675
- const startLine = Math.max(0, line - 1 - contextLines);
6676
- const endLine = Math.min(lines.length, line + contextLines);
6677
- return lines.slice(startLine, endLine).join("\n").trim();
6678
+ const allLines = content.split("\n");
6679
+ let fmLines = 0;
6680
+ if (allLines[0]?.trimEnd() === "---") {
6681
+ for (let i = 1; i < allLines.length; i++) {
6682
+ if (allLines[i].trimEnd() === "---") {
6683
+ fmLines = i + 1;
6684
+ break;
6685
+ }
6686
+ }
6687
+ }
6688
+ const absLine = line + fmLines;
6689
+ const startLine = Math.max(0, absLine - 1 - contextLines);
6690
+ const endLine = Math.min(allLines.length, absLine + contextLines);
6691
+ return allLines.slice(startLine, endLine).join("\n").trim();
6678
6692
  } catch {
6679
6693
  return "";
6680
6694
  }
@@ -7299,10 +7313,28 @@ function getActivitySummary(index, days) {
7299
7313
  import { SCHEMA_VERSION } from "@velvetmonkey/vault-core";
7300
7314
 
7301
7315
  // src/core/shared/indexActivity.ts
7316
+ function createStepTracker() {
7317
+ const steps = [];
7318
+ let current = null;
7319
+ return {
7320
+ steps,
7321
+ start(name, input) {
7322
+ current = { name, input, startTime: Date.now() };
7323
+ },
7324
+ end(output) {
7325
+ if (!current) return;
7326
+ steps.push({ name: current.name, duration_ms: Date.now() - current.startTime, input: current.input, output });
7327
+ current = null;
7328
+ },
7329
+ skip(name, reason) {
7330
+ steps.push({ name, duration_ms: 0, input: {}, output: {}, skipped: true, skip_reason: reason });
7331
+ }
7332
+ };
7333
+ }
7302
7334
  function recordIndexEvent(stateDb2, event) {
7303
7335
  stateDb2.db.prepare(
7304
- `INSERT INTO index_events (timestamp, trigger, duration_ms, success, note_count, files_changed, changed_paths, error)
7305
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
7336
+ `INSERT INTO index_events (timestamp, trigger, duration_ms, success, note_count, files_changed, changed_paths, error, steps)
7337
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
7306
7338
  ).run(
7307
7339
  Date.now(),
7308
7340
  event.trigger,
@@ -7311,7 +7343,8 @@ function recordIndexEvent(stateDb2, event) {
7311
7343
  event.note_count ?? null,
7312
7344
  event.files_changed ?? null,
7313
7345
  event.changed_paths ? JSON.stringify(event.changed_paths) : null,
7314
- event.error ?? null
7346
+ event.error ?? null,
7347
+ event.steps ? JSON.stringify(event.steps) : null
7315
7348
  );
7316
7349
  }
7317
7350
  function rowToEvent(row) {
@@ -7324,7 +7357,8 @@ function rowToEvent(row) {
7324
7357
  note_count: row.note_count,
7325
7358
  files_changed: row.files_changed,
7326
7359
  changed_paths: row.changed_paths ? JSON.parse(row.changed_paths) : null,
7327
- error: row.error
7360
+ error: row.error,
7361
+ steps: row.steps ? JSON.parse(row.steps) : null
7328
7362
  };
7329
7363
  }
7330
7364
  function getRecentIndexEvents(stateDb2, limit = 20) {
@@ -7402,6 +7436,7 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
7402
7436
  note_count: z3.coerce.number().describe("Number of notes in the index"),
7403
7437
  entity_count: z3.coerce.number().describe("Number of linkable entities (titles + aliases)"),
7404
7438
  tag_count: z3.coerce.number().describe("Number of unique tags"),
7439
+ link_count: z3.coerce.number().describe("Total number of outgoing wikilinks"),
7405
7440
  periodic_notes: z3.array(PeriodicNoteInfoSchema).optional().describe("Detected periodic note conventions"),
7406
7441
  config: z3.record(z3.unknown()).optional().describe("Current flywheel config (paths, templates, etc.)"),
7407
7442
  last_rebuild: z3.object({
@@ -7410,11 +7445,27 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
7410
7445
  duration_ms: z3.number(),
7411
7446
  ago_seconds: z3.number()
7412
7447
  }).optional().describe("Most recent index rebuild event"),
7448
+ last_pipeline: z3.object({
7449
+ timestamp: z3.number(),
7450
+ trigger: z3.string(),
7451
+ duration_ms: z3.number(),
7452
+ files_changed: z3.number().nullable(),
7453
+ steps: z3.array(z3.object({
7454
+ name: z3.string(),
7455
+ duration_ms: z3.number(),
7456
+ input: z3.record(z3.unknown()),
7457
+ output: z3.record(z3.unknown()),
7458
+ skipped: z3.boolean().optional(),
7459
+ skip_reason: z3.string().optional()
7460
+ }))
7461
+ }).optional().describe("Most recent watcher pipeline run with per-step timing"),
7413
7462
  fts5_ready: z3.boolean().describe("Whether the FTS5 keyword search index is ready"),
7414
7463
  fts5_building: z3.boolean().describe("Whether the FTS5 keyword search index is currently building"),
7415
7464
  embeddings_building: z3.boolean().describe("Whether semantic embeddings are currently building"),
7416
7465
  embeddings_ready: z3.boolean().describe("Whether semantic embeddings have been built (enables hybrid keyword+semantic search)"),
7417
7466
  embeddings_count: z3.coerce.number().describe("Number of notes with semantic embeddings"),
7467
+ tasks_ready: z3.boolean().describe("Whether the task cache is ready to serve queries"),
7468
+ tasks_building: z3.boolean().describe("Whether the task cache is currently rebuilding"),
7418
7469
  recommendations: z3.array(z3.string()).describe("Suggested actions if any issues detected")
7419
7470
  };
7420
7471
  server2.registerTool(
@@ -7455,6 +7506,10 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
7455
7506
  const noteCount = indexBuilt ? index.notes.size : 0;
7456
7507
  const entityCount = indexBuilt ? index.entities.size : 0;
7457
7508
  const tagCount = indexBuilt ? index.tags.size : 0;
7509
+ let linkCount = 0;
7510
+ if (indexBuilt) {
7511
+ for (const note of index.notes.values()) linkCount += note.outlinks.length;
7512
+ }
7458
7513
  if (indexBuilt && noteCount === 0 && vaultAccessible) {
7459
7514
  recommendations.push("No notes found in vault. Is PROJECT_PATH pointing to a markdown vault?");
7460
7515
  }
@@ -7500,6 +7555,23 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
7500
7555
  } catch {
7501
7556
  }
7502
7557
  }
7558
+ let lastPipeline;
7559
+ if (stateDb2) {
7560
+ try {
7561
+ const events = getRecentIndexEvents(stateDb2, 1);
7562
+ if (events.length > 0 && events[0].steps && events[0].steps.length > 0) {
7563
+ const evt = events[0];
7564
+ lastPipeline = {
7565
+ timestamp: evt.timestamp,
7566
+ trigger: evt.trigger,
7567
+ duration_ms: evt.duration_ms,
7568
+ files_changed: evt.files_changed,
7569
+ steps: evt.steps
7570
+ };
7571
+ }
7572
+ } catch {
7573
+ }
7574
+ }
7503
7575
  const ftsState = getFTS5State();
7504
7576
  const output = {
7505
7577
  status,
@@ -7515,14 +7587,18 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
7515
7587
  note_count: noteCount,
7516
7588
  entity_count: entityCount,
7517
7589
  tag_count: tagCount,
7590
+ link_count: linkCount,
7518
7591
  periodic_notes: periodicNotes && periodicNotes.length > 0 ? periodicNotes : void 0,
7519
7592
  config: configInfo,
7520
7593
  last_rebuild: lastRebuild,
7594
+ last_pipeline: lastPipeline,
7521
7595
  fts5_ready: ftsState.ready,
7522
7596
  fts5_building: ftsState.building,
7523
7597
  embeddings_building: isEmbeddingsBuilding(),
7524
7598
  embeddings_ready: hasEmbeddingsIndex(),
7525
7599
  embeddings_count: getEmbeddingsCount(),
7600
+ tasks_ready: isTaskCacheReady(),
7601
+ tasks_building: isTaskCacheBuilding(),
7526
7602
  recommendations
7527
7603
  };
7528
7604
  return {
@@ -7999,6 +8075,64 @@ import * as fs11 from "fs";
7999
8075
  import * as path12 from "path";
8000
8076
  import { z as z5 } from "zod";
8001
8077
  import { scanVaultEntities as scanVaultEntities2, getEntityIndexFromDb as getEntityIndexFromDb2 } from "@velvetmonkey/vault-core";
8078
+
8079
+ // src/core/read/aliasSuggestions.ts
8080
+ function generateAliasCandidates(entityName, existingAliases) {
8081
+ const existing = new Set(existingAliases.map((a) => a.toLowerCase()));
8082
+ const candidates = [];
8083
+ const words = entityName.split(/[\s-]+/).filter((w) => w.length > 0);
8084
+ if (words.length >= 2) {
8085
+ const acronym = words.map((w) => w[0]).join("").toUpperCase();
8086
+ if (acronym.length >= 2 && acronym.length <= 6 && !existing.has(acronym.toLowerCase())) {
8087
+ candidates.push({ candidate: acronym, type: "acronym" });
8088
+ }
8089
+ if (words.length >= 3) {
8090
+ const short = words[0];
8091
+ if (short.length >= 3 && !existing.has(short.toLowerCase())) {
8092
+ candidates.push({ candidate: short, type: "short_form" });
8093
+ }
8094
+ }
8095
+ }
8096
+ return candidates;
8097
+ }
8098
+ function suggestEntityAliases(stateDb2, folder) {
8099
+ const db4 = stateDb2.db;
8100
+ const entities = folder ? db4.prepare(
8101
+ "SELECT name, path, aliases_json FROM entities WHERE path LIKE ? || '/%'"
8102
+ ).all(folder) : db4.prepare("SELECT name, path, aliases_json FROM entities").all();
8103
+ const allEntityNames = new Set(
8104
+ db4.prepare("SELECT name_lower FROM entities").all().map((r) => r.name_lower)
8105
+ );
8106
+ const suggestions = [];
8107
+ const countStmt = db4.prepare(
8108
+ "SELECT COUNT(*) as cnt FROM notes_fts WHERE content MATCH ?"
8109
+ );
8110
+ for (const row of entities) {
8111
+ const aliases = row.aliases_json ? JSON.parse(row.aliases_json) : [];
8112
+ const candidates = generateAliasCandidates(row.name, aliases);
8113
+ for (const { candidate, type } of candidates) {
8114
+ if (allEntityNames.has(candidate.toLowerCase())) continue;
8115
+ let mentions = 0;
8116
+ try {
8117
+ const result = countStmt.get(`"${candidate}"`);
8118
+ mentions = result?.cnt ?? 0;
8119
+ } catch {
8120
+ }
8121
+ suggestions.push({
8122
+ entity: row.name,
8123
+ entity_path: row.path,
8124
+ current_aliases: aliases,
8125
+ candidate,
8126
+ type,
8127
+ mentions
8128
+ });
8129
+ }
8130
+ }
8131
+ suggestions.sort((a, b) => b.mentions - a.mentions || a.entity.localeCompare(b.entity));
8132
+ return suggestions;
8133
+ }
8134
+
8135
+ // src/tools/read/system.ts
8002
8136
  function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfig, getStateDb) {
8003
8137
  const RefreshIndexOutputSchema = {
8004
8138
  success: z5.boolean().describe("Whether the refresh succeeded"),
@@ -8485,6 +8619,35 @@ function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfi
8485
8619
  };
8486
8620
  }
8487
8621
  );
8622
+ server2.registerTool(
8623
+ "suggest_entity_aliases",
8624
+ {
8625
+ title: "Suggest Entity Aliases",
8626
+ description: "Generate alias suggestions for entities in a folder based on acronyms and short forms, validated against vault content.",
8627
+ inputSchema: {
8628
+ folder: z5.string().optional().describe("Folder path to scope suggestions to"),
8629
+ limit: z5.number().default(20).describe("Max suggestions to return")
8630
+ }
8631
+ },
8632
+ async ({
8633
+ folder,
8634
+ limit: requestedLimit
8635
+ }) => {
8636
+ const stateDb2 = getStateDb?.();
8637
+ if (!stateDb2) {
8638
+ return {
8639
+ content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available" }) }]
8640
+ };
8641
+ }
8642
+ const suggestions = suggestEntityAliases(stateDb2, folder || void 0);
8643
+ const limit = Math.min(requestedLimit ?? 20, 50);
8644
+ const limited = suggestions.slice(0, limit);
8645
+ const output = { suggestion_count: limited.length, suggestions: limited };
8646
+ return {
8647
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }]
8648
+ };
8649
+ }
8650
+ );
8488
8651
  }
8489
8652
 
8490
8653
  // src/tools/read/primitives.ts
@@ -8799,6 +8962,8 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
8799
8962
  content: [{ type: "text", text: JSON.stringify({
8800
8963
  total_count: result2.total,
8801
8964
  open_count: result2.open_count,
8965
+ completed_count: result2.completed_count,
8966
+ cancelled_count: result2.cancelled_count,
8802
8967
  returned_count: result2.tasks.length,
8803
8968
  tasks: result2.tasks
8804
8969
  }, null, 2) }]
@@ -8831,6 +8996,8 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
8831
8996
  content: [{ type: "text", text: JSON.stringify({
8832
8997
  total_count: result.total,
8833
8998
  open_count: result.open_count,
8999
+ completed_count: result.completed_count,
9000
+ cancelled_count: result.cancelled_count,
8834
9001
  returned_count: paged.length,
8835
9002
  tasks: paged
8836
9003
  }, null, 2) }]
@@ -9686,6 +9853,35 @@ function isPeriodicNote(notePath) {
9686
9853
  const folder = notePath.split("/")[0]?.toLowerCase() || "";
9687
9854
  return patterns.some((p) => p.test(nameWithoutExt)) || periodicFolders.includes(folder);
9688
9855
  }
9856
+ function getExcludedPaths(index, config) {
9857
+ const excluded = /* @__PURE__ */ new Set();
9858
+ const excludeTags = new Set((config.exclude_analysis_tags ?? []).map((t) => t.toLowerCase()));
9859
+ const excludeEntities = new Set((config.exclude_entities ?? []).map((e) => e.toLowerCase()));
9860
+ if (excludeTags.size === 0 && excludeEntities.size === 0) return excluded;
9861
+ for (const note of index.notes.values()) {
9862
+ if (excludeTags.size > 0) {
9863
+ const tags = note.frontmatter?.tags;
9864
+ const tagList = Array.isArray(tags) ? tags : typeof tags === "string" ? [tags] : [];
9865
+ if (tagList.some((t) => excludeTags.has(String(t).toLowerCase()))) {
9866
+ excluded.add(note.path);
9867
+ continue;
9868
+ }
9869
+ }
9870
+ if (excludeEntities.size > 0) {
9871
+ if (excludeEntities.has(note.title.toLowerCase())) {
9872
+ excluded.add(note.path);
9873
+ continue;
9874
+ }
9875
+ for (const alias of note.aliases) {
9876
+ if (excludeEntities.has(alias.toLowerCase())) {
9877
+ excluded.add(note.path);
9878
+ break;
9879
+ }
9880
+ }
9881
+ }
9882
+ }
9883
+ return excluded;
9884
+ }
9689
9885
  function registerGraphAnalysisTools(server2, getIndex, getVaultPath, getStateDb, getConfig) {
9690
9886
  server2.registerTool(
9691
9887
  "graph_analysis",
@@ -9707,9 +9903,11 @@ function registerGraphAnalysisTools(server2, getIndex, getVaultPath, getStateDb,
9707
9903
  requireIndex();
9708
9904
  const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
9709
9905
  const index = getIndex();
9906
+ const config = getConfig?.() ?? {};
9907
+ const excludedPaths = getExcludedPaths(index, config);
9710
9908
  switch (analysis) {
9711
9909
  case "orphans": {
9712
- const allOrphans = findOrphanNotes(index, folder).filter((o) => !isPeriodicNote(o.path));
9910
+ const allOrphans = findOrphanNotes(index, folder).filter((o) => !isPeriodicNote(o.path) && !excludedPaths.has(o.path));
9713
9911
  const orphans = allOrphans.slice(offset, offset + limit);
9714
9912
  return {
9715
9913
  content: [{ type: "text", text: JSON.stringify({
@@ -9726,7 +9924,7 @@ function registerGraphAnalysisTools(server2, getIndex, getVaultPath, getStateDb,
9726
9924
  };
9727
9925
  }
9728
9926
  case "dead_ends": {
9729
- const allResults = findDeadEnds(index, folder, min_backlinks);
9927
+ const allResults = findDeadEnds(index, folder, min_backlinks).filter((n) => !excludedPaths.has(n.path));
9730
9928
  const result = allResults.slice(offset, offset + limit);
9731
9929
  return {
9732
9930
  content: [{ type: "text", text: JSON.stringify({
@@ -9739,7 +9937,7 @@ function registerGraphAnalysisTools(server2, getIndex, getVaultPath, getStateDb,
9739
9937
  };
9740
9938
  }
9741
9939
  case "sources": {
9742
- const allResults = findSources(index, folder, min_outlinks);
9940
+ const allResults = findSources(index, folder, min_outlinks).filter((n) => !excludedPaths.has(n.path));
9743
9941
  const result = allResults.slice(offset, offset + limit);
9744
9942
  return {
9745
9943
  content: [{ type: "text", text: JSON.stringify({
@@ -9752,17 +9950,7 @@ function registerGraphAnalysisTools(server2, getIndex, getVaultPath, getStateDb,
9752
9950
  };
9753
9951
  }
9754
9952
  case "hubs": {
9755
- const excludeTags = new Set(
9756
- (getConfig?.()?.exclude_analysis_tags ?? []).map((t) => t.toLowerCase())
9757
- );
9758
- const allHubs = findHubNotes(index, min_links).filter((h) => {
9759
- if (excludeTags.size === 0) return true;
9760
- const note = index.notes.get(h.path);
9761
- if (!note) return true;
9762
- const tags = note.frontmatter?.tags;
9763
- const tagList = Array.isArray(tags) ? tags : typeof tags === "string" ? [tags] : [];
9764
- return !tagList.some((t) => excludeTags.has(String(t).toLowerCase()));
9765
- });
9953
+ const allHubs = findHubNotes(index, min_links).filter((h) => !excludedPaths.has(h.path));
9766
9954
  const hubs = allHubs.slice(offset, offset + limit);
9767
9955
  return {
9768
9956
  content: [{ type: "text", text: JSON.stringify({
@@ -9788,7 +9976,7 @@ function registerGraphAnalysisTools(server2, getIndex, getVaultPath, getStateDb,
9788
9976
  }, null, 2) }]
9789
9977
  };
9790
9978
  }
9791
- const result = getStaleNotes(index, days, min_backlinks).slice(0, limit);
9979
+ const result = getStaleNotes(index, days, min_backlinks).filter((n) => !excludedPaths.has(n.path)).slice(0, limit);
9792
9980
  return {
9793
9981
  content: [{ type: "text", text: JSON.stringify({
9794
9982
  analysis: "stale",
@@ -9804,7 +9992,7 @@ function registerGraphAnalysisTools(server2, getIndex, getVaultPath, getStateDb,
9804
9992
  case "immature": {
9805
9993
  const vaultPath2 = getVaultPath();
9806
9994
  const allNotes = Array.from(index.notes.values()).filter(
9807
- (note) => (!folder || note.path.startsWith(folder + "/") || note.path.substring(0, note.path.lastIndexOf("/")) === folder) && !isPeriodicNote(note.path)
9995
+ (note) => (!folder || note.path.startsWith(folder + "/") || note.path.substring(0, note.path.lastIndexOf("/")) === folder) && !isPeriodicNote(note.path) && !excludedPaths.has(note.path)
9808
9996
  );
9809
9997
  const conventions = inferFolderConventions(index, folder, 0.5);
9810
9998
  const expectedFields = conventions.inferred_fields.map((f) => f.name);
@@ -9889,22 +10077,20 @@ function registerGraphAnalysisTools(server2, getIndex, getVaultPath, getStateDb,
9889
10077
  }
9890
10078
  const daysBack = days ?? 30;
9891
10079
  let hubs = getEmergingHubs(db4, daysBack);
9892
- const excludeTags = new Set(
9893
- (getConfig?.()?.exclude_analysis_tags ?? []).map((t) => t.toLowerCase())
9894
- );
9895
- if (excludeTags.size > 0) {
10080
+ if (excludedPaths.size > 0) {
9896
10081
  const notesByTitle = /* @__PURE__ */ new Map();
9897
10082
  for (const note of index.notes.values()) {
9898
10083
  notesByTitle.set(note.title.toLowerCase(), note);
9899
10084
  }
9900
10085
  hubs = hubs.filter((hub) => {
9901
10086
  const note = notesByTitle.get(hub.entity.toLowerCase());
9902
- if (!note) return true;
9903
- const tags = note.frontmatter?.tags;
9904
- const tagList = Array.isArray(tags) ? tags : typeof tags === "string" ? [tags] : [];
9905
- return !tagList.some((t) => excludeTags.has(String(t).toLowerCase()));
10087
+ return !note || !excludedPaths.has(note.path);
9906
10088
  });
9907
10089
  }
10090
+ const excludeEntities = new Set((config.exclude_entities ?? []).map((e) => e.toLowerCase()));
10091
+ if (excludeEntities.size > 0) {
10092
+ hubs = hubs.filter((hub) => !excludeEntities.has(hub.entity.toLowerCase()));
10093
+ }
9908
10094
  return {
9909
10095
  content: [{ type: "text", text: JSON.stringify({
9910
10096
  analysis: "emerging_hubs",
@@ -11141,41 +11327,58 @@ async function withVaultFile(options, operation) {
11141
11327
  if (existsError) {
11142
11328
  return formatMcpResult(existsError);
11143
11329
  }
11144
- const { content, frontmatter, lineEnding } = await readVaultFile(vaultPath2, notePath);
11145
- const writeStateDb = getWriteStateDb();
11146
- if (writeStateDb) {
11147
- processImplicitFeedback(writeStateDb, notePath, content);
11330
+ const runMutation = async () => {
11331
+ const { content, frontmatter: frontmatter2, lineEnding: lineEnding2, mtimeMs } = await readVaultFile(vaultPath2, notePath);
11332
+ const writeStateDb = getWriteStateDb();
11333
+ if (writeStateDb) {
11334
+ processImplicitFeedback(writeStateDb, notePath, content);
11335
+ }
11336
+ let sectionBoundary;
11337
+ if (section) {
11338
+ const sectionResult = ensureSectionExists(content, section, notePath);
11339
+ if ("error" in sectionResult) {
11340
+ return { error: sectionResult.error };
11341
+ }
11342
+ sectionBoundary = sectionResult.boundary;
11343
+ }
11344
+ const ctx = {
11345
+ content,
11346
+ frontmatter: frontmatter2,
11347
+ lineEnding: lineEnding2,
11348
+ sectionBoundary,
11349
+ vaultPath: vaultPath2,
11350
+ notePath
11351
+ };
11352
+ const opResult2 = await operation(ctx);
11353
+ return { opResult: opResult2, frontmatter: frontmatter2, lineEnding: lineEnding2, mtimeMs };
11354
+ };
11355
+ let result = await runMutation();
11356
+ if ("error" in result) {
11357
+ return formatMcpResult(result.error);
11148
11358
  }
11149
- let sectionBoundary;
11150
- if (section) {
11151
- const sectionResult = ensureSectionExists(content, section, notePath);
11152
- if ("error" in sectionResult) {
11153
- return formatMcpResult(sectionResult.error);
11359
+ const fullPath = path19.join(vaultPath2, notePath);
11360
+ const statBefore = await fs19.stat(fullPath);
11361
+ if (statBefore.mtimeMs !== result.mtimeMs) {
11362
+ console.warn(`[withVaultFile] External modification detected on ${notePath}, re-reading and retrying`);
11363
+ result = await runMutation();
11364
+ if ("error" in result) {
11365
+ return formatMcpResult(result.error);
11154
11366
  }
11155
- sectionBoundary = sectionResult.boundary;
11156
11367
  }
11157
- const ctx = {
11158
- content,
11159
- frontmatter,
11160
- lineEnding,
11161
- sectionBoundary,
11162
- vaultPath: vaultPath2,
11163
- notePath
11164
- };
11165
- const opResult = await operation(ctx);
11368
+ const { opResult, frontmatter, lineEnding } = result;
11166
11369
  let finalFrontmatter = opResult.updatedFrontmatter ?? frontmatter;
11167
11370
  if (scoping && (scoping.agent_id || scoping.session_id)) {
11168
11371
  finalFrontmatter = injectMutationMetadata(finalFrontmatter, scoping);
11169
11372
  }
11170
11373
  await writeVaultFile(vaultPath2, notePath, opResult.updatedContent, finalFrontmatter, lineEnding);
11171
11374
  const gitInfo = await handleGitCommit(vaultPath2, notePath, commit, commitPrefix);
11172
- const result = successResult(notePath, opResult.message, gitInfo, {
11375
+ const successRes = successResult(notePath, opResult.message, gitInfo, {
11173
11376
  preview: opResult.preview,
11174
11377
  warnings: opResult.warnings,
11175
11378
  outputIssues: opResult.outputIssues,
11176
11379
  normalizationChanges: opResult.normalizationChanges
11177
11380
  });
11178
- return formatMcpResult(result);
11381
+ return formatMcpResult(successRes);
11179
11382
  } catch (error) {
11180
11383
  const extras = {};
11181
11384
  if (error instanceof DiagnosticError) {
@@ -15664,6 +15867,7 @@ async function main() {
15664
15867
  setFTS5Database(stateDb.db);
15665
15868
  setEmbeddingsDatabase(stateDb.db);
15666
15869
  setTaskCacheDatabase(stateDb.db);
15870
+ serverLog("statedb", "Injected FTS5, embeddings, task cache handles");
15667
15871
  loadEntityEmbeddingsToMemory();
15668
15872
  setWriteStateDb(stateDb);
15669
15873
  } catch (err) {
@@ -15712,7 +15916,8 @@ async function main() {
15712
15916
  vaultIndex = cachedIndex;
15713
15917
  setIndexState("ready");
15714
15918
  const duration = Date.now() - startTime;
15715
- serverLog("index", `Loaded from cache in ${duration}ms \u2014 ${cachedIndex.notes.size} notes`);
15919
+ const cacheAge = cachedIndex.builtAt ? Math.round((Date.now() - cachedIndex.builtAt.getTime()) / 1e3) : 0;
15920
+ serverLog("index", `Cache hit: ${cachedIndex.notes.size} notes, ${cacheAge}s old \u2014 loaded in ${duration}ms`);
15716
15921
  if (stateDb) {
15717
15922
  recordIndexEvent(stateDb, {
15718
15923
  trigger: "startup_cache",
@@ -15722,7 +15927,7 @@ async function main() {
15722
15927
  }
15723
15928
  runPostIndexWork(vaultIndex);
15724
15929
  } else {
15725
- serverLog("index", "Building vault index...");
15930
+ serverLog("index", "Cache miss: building from scratch");
15726
15931
  try {
15727
15932
  vaultIndex = await buildVaultIndex(vaultPath);
15728
15933
  setIndexState("ready");
@@ -15793,9 +15998,13 @@ async function updateEntitiesInStateDb() {
15793
15998
  }
15794
15999
  }
15795
16000
  async function runPostIndexWork(index) {
16001
+ const postStart = Date.now();
16002
+ serverLog("index", "Scanning entities...");
15796
16003
  await updateEntitiesInStateDb();
15797
16004
  await initializeEntityIndex(vaultPath);
16005
+ serverLog("index", "Entity index initialized");
15798
16006
  await exportHubScores(index, stateDb);
16007
+ serverLog("index", "Hub scores exported");
15799
16008
  if (stateDb) {
15800
16009
  try {
15801
16010
  const metrics = computeMetrics(index, stateDb);
@@ -15820,6 +16029,7 @@ async function runPostIndexWork(index) {
15820
16029
  if (stateDb) {
15821
16030
  try {
15822
16031
  updateSuppressionList(stateDb);
16032
+ serverLog("index", "Suppression list updated");
15823
16033
  } catch (err) {
15824
16034
  serverLog("server", `Failed to update suppression list: ${err instanceof Error ? err.message : err}`, "error");
15825
16035
  }
@@ -15830,9 +16040,15 @@ async function runPostIndexWork(index) {
15830
16040
  saveConfig(stateDb, inferred, existing);
15831
16041
  }
15832
16042
  flywheelConfig = loadConfig(stateDb);
16043
+ const configKeys = Object.keys(flywheelConfig).filter((k) => flywheelConfig[k] != null);
16044
+ serverLog("config", `Config inferred: ${configKeys.join(", ")}`);
15833
16045
  if (stateDb) {
15834
- refreshIfStale(vaultPath, index, flywheelConfig.exclude_task_tags);
15835
- serverLog("tasks", "Task cache ready");
16046
+ if (isTaskCacheStale()) {
16047
+ serverLog("tasks", "Task cache stale, rebuilding...");
16048
+ refreshIfStale(vaultPath, index, flywheelConfig.exclude_task_tags);
16049
+ } else {
16050
+ serverLog("tasks", "Task cache fresh, skipping rebuild");
16051
+ }
15836
16052
  }
15837
16053
  if (flywheelConfig.vault_name) {
15838
16054
  serverLog("config", `Vault: ${flywheelConfig.vault_name}`);
@@ -15878,36 +16094,47 @@ async function runPostIndexWork(index) {
15878
16094
  serverLog("watcher", `Processing ${batch.events.length} file changes`);
15879
16095
  const batchStart = Date.now();
15880
16096
  const changedPaths = batch.events.map((e) => e.path);
16097
+ const tracker = createStepTracker();
15881
16098
  try {
16099
+ tracker.start("index_rebuild", { files_changed: batch.events.length, changed_paths: changedPaths });
15882
16100
  vaultIndex = await buildVaultIndex(vaultPath);
15883
16101
  setIndexState("ready");
15884
- const duration = Date.now() - batchStart;
15885
- serverLog("watcher", `Index rebuilt in ${duration}ms`);
15886
- if (stateDb) {
15887
- recordIndexEvent(stateDb, {
15888
- trigger: "watcher",
15889
- duration_ms: duration,
15890
- note_count: vaultIndex.notes.size,
15891
- files_changed: batch.events.length,
15892
- changed_paths: changedPaths
15893
- });
15894
- }
16102
+ tracker.end({ note_count: vaultIndex.notes.size, entity_count: vaultIndex.entities.size, tag_count: vaultIndex.tags.size });
16103
+ serverLog("watcher", `Index rebuilt: ${vaultIndex.notes.size} notes, ${vaultIndex.entities.size} entities`);
16104
+ tracker.start("entity_scan", { note_count: vaultIndex.notes.size });
15895
16105
  await updateEntitiesInStateDb();
15896
- await exportHubScores(vaultIndex, stateDb);
16106
+ const entityCount = stateDb ? getAllEntitiesFromDb3(stateDb).length : 0;
16107
+ tracker.end({ entity_count: entityCount });
16108
+ serverLog("watcher", `Entity scan: ${entityCount} entities`);
16109
+ tracker.start("hub_scores", { entity_count: entityCount });
16110
+ const hubUpdated = await exportHubScores(vaultIndex, stateDb);
16111
+ tracker.end({ updated: hubUpdated ?? 0 });
16112
+ serverLog("watcher", `Hub scores: ${hubUpdated ?? 0} updated`);
15897
16113
  if (hasEmbeddingsIndex()) {
16114
+ tracker.start("note_embeddings", { files: batch.events.length });
16115
+ let embUpdated = 0;
16116
+ let embRemoved = 0;
15898
16117
  for (const event of batch.events) {
15899
16118
  try {
15900
16119
  if (event.type === "delete") {
15901
16120
  removeEmbedding(event.path);
16121
+ embRemoved++;
15902
16122
  } else if (event.path.endsWith(".md")) {
15903
16123
  const absPath = path29.join(vaultPath, event.path);
15904
16124
  await updateEmbedding(event.path, absPath);
16125
+ embUpdated++;
15905
16126
  }
15906
16127
  } catch {
15907
16128
  }
15908
16129
  }
16130
+ tracker.end({ updated: embUpdated, removed: embRemoved });
16131
+ serverLog("watcher", `Note embeddings: ${embUpdated} updated, ${embRemoved} removed`);
16132
+ } else {
16133
+ tracker.skip("note_embeddings", "not built");
15909
16134
  }
15910
16135
  if (hasEntityEmbeddingsIndex() && stateDb) {
16136
+ tracker.start("entity_embeddings", { files: batch.events.length });
16137
+ let entEmbUpdated = 0;
15911
16138
  try {
15912
16139
  const allEntities = getAllEntitiesFromDb3(stateDb);
15913
16140
  for (const event of batch.events) {
@@ -15920,28 +16147,58 @@ async function runPostIndexWork(index) {
15920
16147
  category: entity.category,
15921
16148
  aliases: entity.aliases
15922
16149
  }, vaultPath);
16150
+ entEmbUpdated++;
15923
16151
  }
15924
16152
  }
15925
16153
  } catch {
15926
16154
  }
16155
+ tracker.end({ updated: entEmbUpdated });
16156
+ serverLog("watcher", `Entity embeddings: ${entEmbUpdated} updated`);
16157
+ } else {
16158
+ tracker.skip("entity_embeddings", !stateDb ? "no stateDb" : "not built");
15927
16159
  }
15928
16160
  if (stateDb) {
16161
+ tracker.start("index_cache", { note_count: vaultIndex.notes.size });
15929
16162
  try {
15930
16163
  saveVaultIndexToCache(stateDb, vaultIndex);
16164
+ tracker.end({ saved: true });
16165
+ serverLog("watcher", "Index cache saved");
15931
16166
  } catch (err) {
16167
+ tracker.end({ saved: false, error: err instanceof Error ? err.message : String(err) });
15932
16168
  serverLog("index", `Failed to update index cache: ${err instanceof Error ? err.message : err}`, "error");
15933
16169
  }
16170
+ } else {
16171
+ tracker.skip("index_cache", "no stateDb");
15934
16172
  }
16173
+ tracker.start("task_cache", { files: batch.events.length });
16174
+ let taskUpdated = 0;
16175
+ let taskRemoved = 0;
15935
16176
  for (const event of batch.events) {
15936
16177
  try {
15937
16178
  if (event.type === "delete") {
15938
16179
  removeTaskCacheForFile(event.path);
16180
+ taskRemoved++;
15939
16181
  } else if (event.path.endsWith(".md")) {
15940
16182
  await updateTaskCacheForFile(vaultPath, event.path);
16183
+ taskUpdated++;
15941
16184
  }
15942
16185
  } catch {
15943
16186
  }
15944
16187
  }
16188
+ tracker.end({ updated: taskUpdated, removed: taskRemoved });
16189
+ serverLog("watcher", `Task cache: ${taskUpdated} updated, ${taskRemoved} removed`);
16190
+ const duration = Date.now() - batchStart;
16191
+ if (stateDb) {
16192
+ recordIndexEvent(stateDb, {
16193
+ trigger: "watcher",
16194
+ duration_ms: duration,
16195
+ note_count: vaultIndex.notes.size,
16196
+ files_changed: batch.events.length,
16197
+ changed_paths: changedPaths,
16198
+ steps: tracker.steps
16199
+ });
16200
+ }
16201
+ serverLog("watcher", `Batch complete: ${batch.events.length} files, ${duration}ms, ${tracker.steps.length} steps`);
15945
16202
  } catch (err) {
15946
16203
  setIndexState("error");
15947
16204
  setIndexError(err instanceof Error ? err : new Error(String(err)));
@@ -15953,7 +16210,8 @@ async function runPostIndexWork(index) {
15953
16210
  success: false,
15954
16211
  files_changed: batch.events.length,
15955
16212
  changed_paths: changedPaths,
15956
- error: err instanceof Error ? err.message : String(err)
16213
+ error: err instanceof Error ? err.message : String(err),
16214
+ steps: tracker.steps
15957
16215
  });
15958
16216
  }
15959
16217
  serverLog("watcher", `Failed to rebuild index: ${err instanceof Error ? err.message : err}`, "error");
@@ -15969,7 +16227,10 @@ async function runPostIndexWork(index) {
15969
16227
  }
15970
16228
  });
15971
16229
  watcher.start();
16230
+ serverLog("watcher", "File watcher started");
15972
16231
  }
16232
+ const postDuration = Date.now() - postStart;
16233
+ serverLog("server", `Post-index work complete in ${postDuration}ms`);
15973
16234
  }
15974
16235
  if (process.argv.includes("--init-semantic")) {
15975
16236
  (async () => {
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.30",
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.30",
54
54
  "better-sqlite3": "^11.0.0",
55
55
  "chokidar": "^4.0.0",
56
56
  "gray-matter": "^4.0.3",