@velvetmonkey/flywheel-memory 2.0.29 → 2.0.31

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 +291 -116
  2. package/package.json +84 -84
package/dist/index.js CHANGED
@@ -2485,7 +2485,8 @@ import {
2485
2485
  } from "@velvetmonkey/vault-core";
2486
2486
  var DEFAULT_CONFIG = {
2487
2487
  exclude_task_tags: [],
2488
- exclude_analysis_tags: []
2488
+ exclude_analysis_tags: [],
2489
+ exclude_entities: []
2489
2490
  };
2490
2491
  function loadConfig(stateDb2) {
2491
2492
  if (stateDb2) {
@@ -5898,36 +5899,38 @@ async function buildFTS5Index(vaultPath2) {
5898
5899
  if (!db2) {
5899
5900
  throw new Error("FTS5 database not initialized. Call setFTS5Database() first.");
5900
5901
  }
5901
- db2.exec("DELETE FROM notes_fts");
5902
5902
  const files = await scanVault(vaultPath2);
5903
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
+ }
5904
5919
  const insert = db2.prepare(
5905
5920
  "INSERT INTO notes_fts (path, title, frontmatter, content) VALUES (?, ?, ?, ?)"
5906
5921
  );
5907
- const insertMany = db2.transaction((filesToIndex) => {
5908
- let indexed2 = 0;
5909
- for (const file of filesToIndex) {
5910
- try {
5911
- const stats = fs7.statSync(file.absolutePath);
5912
- if (stats.size > MAX_INDEX_FILE_SIZE) {
5913
- continue;
5914
- }
5915
- const raw = fs7.readFileSync(file.absolutePath, "utf-8");
5916
- const { frontmatter, body } = splitFrontmatter(raw);
5917
- const title = file.path.replace(/\.md$/, "").split("/").pop() || file.path;
5918
- insert.run(file.path, title, frontmatter, body);
5919
- indexed2++;
5920
- } catch (err) {
5921
- console.error(`[FTS5] Skipping ${file.path}:`, err);
5922
- }
5923
- }
5924
- return indexed2;
5925
- });
5926
- const indexed = insertMany(indexableFiles);
5927
5922
  const now = /* @__PURE__ */ new Date();
5928
- db2.prepare(
5929
- "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)"
5930
- ).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;
5931
5934
  state = {
5932
5935
  ready: true,
5933
5936
  building: false,
@@ -6187,6 +6190,9 @@ function setTaskCacheDatabase(database) {
6187
6190
  function isTaskCacheReady() {
6188
6191
  return cacheReady && db3 !== null;
6189
6192
  }
6193
+ function isTaskCacheBuilding() {
6194
+ return rebuildInProgress;
6195
+ }
6190
6196
  async function buildTaskCache(vaultPath2, index, excludeTags) {
6191
6197
  if (!db3) {
6192
6198
  throw new Error("Task cache database not initialized. Call setTaskCacheDatabase() first.");
@@ -6195,53 +6201,47 @@ async function buildTaskCache(vaultPath2, index, excludeTags) {
6195
6201
  rebuildInProgress = true;
6196
6202
  const start = Date.now();
6197
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
+ }
6198
6228
  const insertStmt = db3.prepare(`
6199
6229
  INSERT OR REPLACE INTO tasks (path, line, text, status, raw, context, tags_json, due_date)
6200
6230
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
6201
6231
  `);
6202
- const insertAll = db3.transaction(() => {
6232
+ const swapAll = db3.transaction(() => {
6203
6233
  db3.prepare("DELETE FROM tasks").run();
6204
- let count = 0;
6205
- const promises7 = [];
6206
- const notePaths2 = [];
6207
- for (const note of index.notes.values()) {
6208
- notePaths2.push(note.path);
6234
+ for (const row of allRows) {
6235
+ insertStmt.run(...row);
6209
6236
  }
6210
- 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());
6211
6240
  });
6212
- const { notePaths, insertStmt: stmt } = insertAll();
6213
- let totalTasks = 0;
6214
- for (const notePath of notePaths) {
6215
- const absolutePath = path10.join(vaultPath2, notePath);
6216
- const tasks = await extractTasksFromNote(notePath, absolutePath);
6217
- if (tasks.length > 0) {
6218
- const insertBatch = db3.transaction(() => {
6219
- for (const task of tasks) {
6220
- if (excludeTags?.length && excludeTags.some((t) => task.tags.includes(t))) {
6221
- continue;
6222
- }
6223
- stmt.run(
6224
- task.path,
6225
- task.line,
6226
- task.text,
6227
- task.status,
6228
- task.raw,
6229
- task.context ?? null,
6230
- task.tags.length > 0 ? JSON.stringify(task.tags) : null,
6231
- task.due_date ?? null
6232
- );
6233
- totalTasks++;
6234
- }
6235
- });
6236
- insertBatch();
6237
- }
6238
- }
6239
- db3.prepare(
6240
- "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)"
6241
- ).run("task_cache_built", (/* @__PURE__ */ new Date()).toISOString());
6241
+ swapAll();
6242
6242
  cacheReady = true;
6243
6243
  const duration = Date.now() - start;
6244
- 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`);
6245
6245
  } finally {
6246
6246
  rebuildInProgress = false;
6247
6247
  }
@@ -6675,10 +6675,20 @@ async function getContext(vaultPath2, sourcePath, line, contextLines = 1) {
6675
6675
  try {
6676
6676
  const fullPath = path11.join(vaultPath2, sourcePath);
6677
6677
  const content = await fs9.promises.readFile(fullPath, "utf-8");
6678
- const lines = content.split("\n");
6679
- const startLine = Math.max(0, line - 1 - contextLines);
6680
- const endLine = Math.min(lines.length, line + contextLines);
6681
- 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();
6682
6692
  } catch {
6683
6693
  return "";
6684
6694
  }
@@ -7303,10 +7313,28 @@ function getActivitySummary(index, days) {
7303
7313
  import { SCHEMA_VERSION } from "@velvetmonkey/vault-core";
7304
7314
 
7305
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
+ }
7306
7334
  function recordIndexEvent(stateDb2, event) {
7307
7335
  stateDb2.db.prepare(
7308
- `INSERT INTO index_events (timestamp, trigger, duration_ms, success, note_count, files_changed, changed_paths, error)
7309
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
7336
+ `INSERT INTO index_events (timestamp, trigger, duration_ms, success, note_count, files_changed, changed_paths, error, steps)
7337
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
7310
7338
  ).run(
7311
7339
  Date.now(),
7312
7340
  event.trigger,
@@ -7315,7 +7343,8 @@ function recordIndexEvent(stateDb2, event) {
7315
7343
  event.note_count ?? null,
7316
7344
  event.files_changed ?? null,
7317
7345
  event.changed_paths ? JSON.stringify(event.changed_paths) : null,
7318
- event.error ?? null
7346
+ event.error ?? null,
7347
+ event.steps ? JSON.stringify(event.steps) : null
7319
7348
  );
7320
7349
  }
7321
7350
  function rowToEvent(row) {
@@ -7328,9 +7357,41 @@ function rowToEvent(row) {
7328
7357
  note_count: row.note_count,
7329
7358
  files_changed: row.files_changed,
7330
7359
  changed_paths: row.changed_paths ? JSON.parse(row.changed_paths) : null,
7331
- error: row.error
7360
+ error: row.error,
7361
+ steps: row.steps ? JSON.parse(row.steps) : null
7332
7362
  };
7333
7363
  }
7364
+ function getRecentPipelineEvent(stateDb2) {
7365
+ const row = stateDb2.db.prepare(
7366
+ "SELECT * FROM index_events WHERE steps IS NOT NULL ORDER BY timestamp DESC LIMIT 1"
7367
+ ).get();
7368
+ return row ? rowToEvent(row) : null;
7369
+ }
7370
+ function computeEntityDiff(before, after) {
7371
+ const beforeMap = new Map(before.map((e) => [e.nameLower, e]));
7372
+ const afterMap = new Map(after.map((e) => [e.nameLower, e]));
7373
+ const added = [];
7374
+ const removed = [];
7375
+ const alias_changes = [];
7376
+ for (const [key, entity] of afterMap) {
7377
+ if (!beforeMap.has(key)) {
7378
+ added.push(entity.name);
7379
+ } else {
7380
+ const prev = beforeMap.get(key);
7381
+ const prevAliases = JSON.stringify(prev.aliases.sort());
7382
+ const currAliases = JSON.stringify(entity.aliases.sort());
7383
+ if (prevAliases !== currAliases) {
7384
+ alias_changes.push({ entity: entity.name, before: prev.aliases, after: entity.aliases });
7385
+ }
7386
+ }
7387
+ }
7388
+ for (const [key, entity] of beforeMap) {
7389
+ if (!afterMap.has(key)) {
7390
+ removed.push(entity.name);
7391
+ }
7392
+ }
7393
+ return { added, removed, alias_changes };
7394
+ }
7334
7395
  function getRecentIndexEvents(stateDb2, limit = 20) {
7335
7396
  const rows = stateDb2.db.prepare(
7336
7397
  "SELECT * FROM index_events ORDER BY timestamp DESC LIMIT ?"
@@ -7415,11 +7476,27 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
7415
7476
  duration_ms: z3.number(),
7416
7477
  ago_seconds: z3.number()
7417
7478
  }).optional().describe("Most recent index rebuild event"),
7479
+ last_pipeline: z3.object({
7480
+ timestamp: z3.number(),
7481
+ trigger: z3.string(),
7482
+ duration_ms: z3.number(),
7483
+ files_changed: z3.number().nullable(),
7484
+ steps: z3.array(z3.object({
7485
+ name: z3.string(),
7486
+ duration_ms: z3.number(),
7487
+ input: z3.record(z3.unknown()),
7488
+ output: z3.record(z3.unknown()),
7489
+ skipped: z3.boolean().optional(),
7490
+ skip_reason: z3.string().optional()
7491
+ }))
7492
+ }).optional().describe("Most recent watcher pipeline run with per-step timing"),
7418
7493
  fts5_ready: z3.boolean().describe("Whether the FTS5 keyword search index is ready"),
7419
7494
  fts5_building: z3.boolean().describe("Whether the FTS5 keyword search index is currently building"),
7420
7495
  embeddings_building: z3.boolean().describe("Whether semantic embeddings are currently building"),
7421
7496
  embeddings_ready: z3.boolean().describe("Whether semantic embeddings have been built (enables hybrid keyword+semantic search)"),
7422
7497
  embeddings_count: z3.coerce.number().describe("Number of notes with semantic embeddings"),
7498
+ tasks_ready: z3.boolean().describe("Whether the task cache is ready to serve queries"),
7499
+ tasks_building: z3.boolean().describe("Whether the task cache is currently rebuilding"),
7423
7500
  recommendations: z3.array(z3.string()).describe("Suggested actions if any issues detected")
7424
7501
  };
7425
7502
  server2.registerTool(
@@ -7458,7 +7535,7 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
7458
7535
  recommendations.push(`Index is ${Math.floor(indexAge / 60)} minutes old. Consider running refresh_index.`);
7459
7536
  }
7460
7537
  const noteCount = indexBuilt ? index.notes.size : 0;
7461
- const entityCount = indexBuilt ? index.entities.size : 0;
7538
+ const entityCount2 = indexBuilt ? index.entities.size : 0;
7462
7539
  const tagCount = indexBuilt ? index.tags.size : 0;
7463
7540
  let linkCount = 0;
7464
7541
  if (indexBuilt) {
@@ -7509,6 +7586,22 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
7509
7586
  } catch {
7510
7587
  }
7511
7588
  }
7589
+ let lastPipeline;
7590
+ if (stateDb2) {
7591
+ try {
7592
+ const evt = getRecentPipelineEvent(stateDb2);
7593
+ if (evt && evt.steps && evt.steps.length > 0) {
7594
+ lastPipeline = {
7595
+ timestamp: evt.timestamp,
7596
+ trigger: evt.trigger,
7597
+ duration_ms: evt.duration_ms,
7598
+ files_changed: evt.files_changed,
7599
+ steps: evt.steps
7600
+ };
7601
+ }
7602
+ } catch {
7603
+ }
7604
+ }
7512
7605
  const ftsState = getFTS5State();
7513
7606
  const output = {
7514
7607
  status,
@@ -7522,17 +7615,20 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
7522
7615
  index_age_seconds: indexAge,
7523
7616
  index_stale: indexStale,
7524
7617
  note_count: noteCount,
7525
- entity_count: entityCount,
7618
+ entity_count: entityCount2,
7526
7619
  tag_count: tagCount,
7527
7620
  link_count: linkCount,
7528
7621
  periodic_notes: periodicNotes && periodicNotes.length > 0 ? periodicNotes : void 0,
7529
7622
  config: configInfo,
7530
7623
  last_rebuild: lastRebuild,
7624
+ last_pipeline: lastPipeline,
7531
7625
  fts5_ready: ftsState.ready,
7532
7626
  fts5_building: ftsState.building,
7533
7627
  embeddings_building: isEmbeddingsBuilding(),
7534
7628
  embeddings_ready: hasEmbeddingsIndex(),
7535
7629
  embeddings_count: getEmbeddingsCount(),
7630
+ tasks_ready: isTaskCacheReady(),
7631
+ tasks_building: isTaskCacheBuilding(),
7536
7632
  recommendations
7537
7633
  };
7538
7634
  return {
@@ -9787,6 +9883,35 @@ function isPeriodicNote(notePath) {
9787
9883
  const folder = notePath.split("/")[0]?.toLowerCase() || "";
9788
9884
  return patterns.some((p) => p.test(nameWithoutExt)) || periodicFolders.includes(folder);
9789
9885
  }
9886
+ function getExcludedPaths(index, config) {
9887
+ const excluded = /* @__PURE__ */ new Set();
9888
+ const excludeTags = new Set((config.exclude_analysis_tags ?? []).map((t) => t.toLowerCase()));
9889
+ const excludeEntities = new Set((config.exclude_entities ?? []).map((e) => e.toLowerCase()));
9890
+ if (excludeTags.size === 0 && excludeEntities.size === 0) return excluded;
9891
+ for (const note of index.notes.values()) {
9892
+ if (excludeTags.size > 0) {
9893
+ const tags = note.frontmatter?.tags;
9894
+ const tagList = Array.isArray(tags) ? tags : typeof tags === "string" ? [tags] : [];
9895
+ if (tagList.some((t) => excludeTags.has(String(t).toLowerCase()))) {
9896
+ excluded.add(note.path);
9897
+ continue;
9898
+ }
9899
+ }
9900
+ if (excludeEntities.size > 0) {
9901
+ if (excludeEntities.has(note.title.toLowerCase())) {
9902
+ excluded.add(note.path);
9903
+ continue;
9904
+ }
9905
+ for (const alias of note.aliases) {
9906
+ if (excludeEntities.has(alias.toLowerCase())) {
9907
+ excluded.add(note.path);
9908
+ break;
9909
+ }
9910
+ }
9911
+ }
9912
+ }
9913
+ return excluded;
9914
+ }
9790
9915
  function registerGraphAnalysisTools(server2, getIndex, getVaultPath, getStateDb, getConfig) {
9791
9916
  server2.registerTool(
9792
9917
  "graph_analysis",
@@ -9808,9 +9933,11 @@ function registerGraphAnalysisTools(server2, getIndex, getVaultPath, getStateDb,
9808
9933
  requireIndex();
9809
9934
  const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
9810
9935
  const index = getIndex();
9936
+ const config = getConfig?.() ?? {};
9937
+ const excludedPaths = getExcludedPaths(index, config);
9811
9938
  switch (analysis) {
9812
9939
  case "orphans": {
9813
- const allOrphans = findOrphanNotes(index, folder).filter((o) => !isPeriodicNote(o.path));
9940
+ const allOrphans = findOrphanNotes(index, folder).filter((o) => !isPeriodicNote(o.path) && !excludedPaths.has(o.path));
9814
9941
  const orphans = allOrphans.slice(offset, offset + limit);
9815
9942
  return {
9816
9943
  content: [{ type: "text", text: JSON.stringify({
@@ -9827,7 +9954,7 @@ function registerGraphAnalysisTools(server2, getIndex, getVaultPath, getStateDb,
9827
9954
  };
9828
9955
  }
9829
9956
  case "dead_ends": {
9830
- const allResults = findDeadEnds(index, folder, min_backlinks);
9957
+ const allResults = findDeadEnds(index, folder, min_backlinks).filter((n) => !excludedPaths.has(n.path));
9831
9958
  const result = allResults.slice(offset, offset + limit);
9832
9959
  return {
9833
9960
  content: [{ type: "text", text: JSON.stringify({
@@ -9840,7 +9967,7 @@ function registerGraphAnalysisTools(server2, getIndex, getVaultPath, getStateDb,
9840
9967
  };
9841
9968
  }
9842
9969
  case "sources": {
9843
- const allResults = findSources(index, folder, min_outlinks);
9970
+ const allResults = findSources(index, folder, min_outlinks).filter((n) => !excludedPaths.has(n.path));
9844
9971
  const result = allResults.slice(offset, offset + limit);
9845
9972
  return {
9846
9973
  content: [{ type: "text", text: JSON.stringify({
@@ -9853,17 +9980,7 @@ function registerGraphAnalysisTools(server2, getIndex, getVaultPath, getStateDb,
9853
9980
  };
9854
9981
  }
9855
9982
  case "hubs": {
9856
- const excludeTags = new Set(
9857
- (getConfig?.()?.exclude_analysis_tags ?? []).map((t) => t.toLowerCase())
9858
- );
9859
- const allHubs = findHubNotes(index, min_links).filter((h) => {
9860
- if (excludeTags.size === 0) return true;
9861
- const note = index.notes.get(h.path);
9862
- if (!note) return true;
9863
- const tags = note.frontmatter?.tags;
9864
- const tagList = Array.isArray(tags) ? tags : typeof tags === "string" ? [tags] : [];
9865
- return !tagList.some((t) => excludeTags.has(String(t).toLowerCase()));
9866
- });
9983
+ const allHubs = findHubNotes(index, min_links).filter((h) => !excludedPaths.has(h.path));
9867
9984
  const hubs = allHubs.slice(offset, offset + limit);
9868
9985
  return {
9869
9986
  content: [{ type: "text", text: JSON.stringify({
@@ -9889,7 +10006,7 @@ function registerGraphAnalysisTools(server2, getIndex, getVaultPath, getStateDb,
9889
10006
  }, null, 2) }]
9890
10007
  };
9891
10008
  }
9892
- const result = getStaleNotes(index, days, min_backlinks).slice(0, limit);
10009
+ const result = getStaleNotes(index, days, min_backlinks).filter((n) => !excludedPaths.has(n.path)).slice(0, limit);
9893
10010
  return {
9894
10011
  content: [{ type: "text", text: JSON.stringify({
9895
10012
  analysis: "stale",
@@ -9905,7 +10022,7 @@ function registerGraphAnalysisTools(server2, getIndex, getVaultPath, getStateDb,
9905
10022
  case "immature": {
9906
10023
  const vaultPath2 = getVaultPath();
9907
10024
  const allNotes = Array.from(index.notes.values()).filter(
9908
- (note) => (!folder || note.path.startsWith(folder + "/") || note.path.substring(0, note.path.lastIndexOf("/")) === folder) && !isPeriodicNote(note.path)
10025
+ (note) => (!folder || note.path.startsWith(folder + "/") || note.path.substring(0, note.path.lastIndexOf("/")) === folder) && !isPeriodicNote(note.path) && !excludedPaths.has(note.path)
9909
10026
  );
9910
10027
  const conventions = inferFolderConventions(index, folder, 0.5);
9911
10028
  const expectedFields = conventions.inferred_fields.map((f) => f.name);
@@ -9990,22 +10107,20 @@ function registerGraphAnalysisTools(server2, getIndex, getVaultPath, getStateDb,
9990
10107
  }
9991
10108
  const daysBack = days ?? 30;
9992
10109
  let hubs = getEmergingHubs(db4, daysBack);
9993
- const excludeTags = new Set(
9994
- (getConfig?.()?.exclude_analysis_tags ?? []).map((t) => t.toLowerCase())
9995
- );
9996
- if (excludeTags.size > 0) {
10110
+ if (excludedPaths.size > 0) {
9997
10111
  const notesByTitle = /* @__PURE__ */ new Map();
9998
10112
  for (const note of index.notes.values()) {
9999
10113
  notesByTitle.set(note.title.toLowerCase(), note);
10000
10114
  }
10001
10115
  hubs = hubs.filter((hub) => {
10002
10116
  const note = notesByTitle.get(hub.entity.toLowerCase());
10003
- if (!note) return true;
10004
- const tags = note.frontmatter?.tags;
10005
- const tagList = Array.isArray(tags) ? tags : typeof tags === "string" ? [tags] : [];
10006
- return !tagList.some((t) => excludeTags.has(String(t).toLowerCase()));
10117
+ return !note || !excludedPaths.has(note.path);
10007
10118
  });
10008
10119
  }
10120
+ const excludeEntities = new Set((config.exclude_entities ?? []).map((e) => e.toLowerCase()));
10121
+ if (excludeEntities.size > 0) {
10122
+ hubs = hubs.filter((hub) => !excludeEntities.has(hub.entity.toLowerCase()));
10123
+ }
10009
10124
  return {
10010
10125
  content: [{ type: "text", text: JSON.stringify({
10011
10126
  analysis: "emerging_hubs",
@@ -14429,7 +14544,7 @@ function computeMetrics(index, stateDb2) {
14429
14544
  }
14430
14545
  }
14431
14546
  const tagCount = index.tags.size;
14432
- const entityCount = index.entities.size;
14547
+ const entityCount2 = index.entities.size;
14433
14548
  const avgLinksPerNote = noteCount > 0 ? linkCount / noteCount : 0;
14434
14549
  const possibleLinks = noteCount * (noteCount - 1);
14435
14550
  const linkDensity = possibleLinks > 0 ? linkCount / possibleLinks : 0;
@@ -14451,7 +14566,7 @@ function computeMetrics(index, stateDb2) {
14451
14566
  link_count: linkCount,
14452
14567
  orphan_count: orphanCount,
14453
14568
  tag_count: tagCount,
14454
- entity_count: entityCount,
14569
+ entity_count: entityCount2,
14455
14570
  avg_links_per_note: Math.round(avgLinksPerNote * 100) / 100,
14456
14571
  link_density: Math.round(linkDensity * 1e4) / 1e4,
14457
14572
  connected_ratio: Math.round(connectedRatio * 1e3) / 1e3,
@@ -15782,6 +15897,7 @@ async function main() {
15782
15897
  setFTS5Database(stateDb.db);
15783
15898
  setEmbeddingsDatabase(stateDb.db);
15784
15899
  setTaskCacheDatabase(stateDb.db);
15900
+ serverLog("statedb", "Injected FTS5, embeddings, task cache handles");
15785
15901
  loadEntityEmbeddingsToMemory();
15786
15902
  setWriteStateDb(stateDb);
15787
15903
  } catch (err) {
@@ -15830,7 +15946,8 @@ async function main() {
15830
15946
  vaultIndex = cachedIndex;
15831
15947
  setIndexState("ready");
15832
15948
  const duration = Date.now() - startTime;
15833
- serverLog("index", `Loaded from cache in ${duration}ms \u2014 ${cachedIndex.notes.size} notes`);
15949
+ const cacheAge = cachedIndex.builtAt ? Math.round((Date.now() - cachedIndex.builtAt.getTime()) / 1e3) : 0;
15950
+ serverLog("index", `Cache hit: ${cachedIndex.notes.size} notes, ${cacheAge}s old \u2014 loaded in ${duration}ms`);
15834
15951
  if (stateDb) {
15835
15952
  recordIndexEvent(stateDb, {
15836
15953
  trigger: "startup_cache",
@@ -15840,7 +15957,7 @@ async function main() {
15840
15957
  }
15841
15958
  runPostIndexWork(vaultIndex);
15842
15959
  } else {
15843
- serverLog("index", "Building vault index...");
15960
+ serverLog("index", "Cache miss: building from scratch");
15844
15961
  try {
15845
15962
  vaultIndex = await buildVaultIndex(vaultPath);
15846
15963
  setIndexState("ready");
@@ -15911,9 +16028,13 @@ async function updateEntitiesInStateDb() {
15911
16028
  }
15912
16029
  }
15913
16030
  async function runPostIndexWork(index) {
16031
+ const postStart = Date.now();
16032
+ serverLog("index", "Scanning entities...");
15914
16033
  await updateEntitiesInStateDb();
15915
16034
  await initializeEntityIndex(vaultPath);
16035
+ serverLog("index", "Entity index initialized");
15916
16036
  await exportHubScores(index, stateDb);
16037
+ serverLog("index", "Hub scores exported");
15917
16038
  if (stateDb) {
15918
16039
  try {
15919
16040
  const metrics = computeMetrics(index, stateDb);
@@ -15938,6 +16059,7 @@ async function runPostIndexWork(index) {
15938
16059
  if (stateDb) {
15939
16060
  try {
15940
16061
  updateSuppressionList(stateDb);
16062
+ serverLog("index", "Suppression list updated");
15941
16063
  } catch (err) {
15942
16064
  serverLog("server", `Failed to update suppression list: ${err instanceof Error ? err.message : err}`, "error");
15943
16065
  }
@@ -15948,9 +16070,15 @@ async function runPostIndexWork(index) {
15948
16070
  saveConfig(stateDb, inferred, existing);
15949
16071
  }
15950
16072
  flywheelConfig = loadConfig(stateDb);
16073
+ const configKeys = Object.keys(flywheelConfig).filter((k) => flywheelConfig[k] != null);
16074
+ serverLog("config", `Config inferred: ${configKeys.join(", ")}`);
15951
16075
  if (stateDb) {
15952
- refreshIfStale(vaultPath, index, flywheelConfig.exclude_task_tags);
15953
- serverLog("tasks", "Task cache ready");
16076
+ if (isTaskCacheStale()) {
16077
+ serverLog("tasks", "Task cache stale, rebuilding...");
16078
+ refreshIfStale(vaultPath, index, flywheelConfig.exclude_task_tags);
16079
+ } else {
16080
+ serverLog("tasks", "Task cache fresh, skipping rebuild");
16081
+ }
15954
16082
  }
15955
16083
  if (flywheelConfig.vault_name) {
15956
16084
  serverLog("config", `Vault: ${flywheelConfig.vault_name}`);
@@ -15996,36 +16124,49 @@ async function runPostIndexWork(index) {
15996
16124
  serverLog("watcher", `Processing ${batch.events.length} file changes`);
15997
16125
  const batchStart = Date.now();
15998
16126
  const changedPaths = batch.events.map((e) => e.path);
16127
+ const tracker = createStepTracker();
15999
16128
  try {
16129
+ tracker.start("index_rebuild", { files_changed: batch.events.length, changed_paths: changedPaths });
16000
16130
  vaultIndex = await buildVaultIndex(vaultPath);
16001
16131
  setIndexState("ready");
16002
- const duration = Date.now() - batchStart;
16003
- serverLog("watcher", `Index rebuilt in ${duration}ms`);
16004
- if (stateDb) {
16005
- recordIndexEvent(stateDb, {
16006
- trigger: "watcher",
16007
- duration_ms: duration,
16008
- note_count: vaultIndex.notes.size,
16009
- files_changed: batch.events.length,
16010
- changed_paths: changedPaths
16011
- });
16012
- }
16132
+ tracker.end({ note_count: vaultIndex.notes.size, entity_count: vaultIndex.entities.size, tag_count: vaultIndex.tags.size });
16133
+ serverLog("watcher", `Index rebuilt: ${vaultIndex.notes.size} notes, ${vaultIndex.entities.size} entities`);
16134
+ const entitiesBefore = stateDb ? getAllEntitiesFromDb3(stateDb) : [];
16135
+ tracker.start("entity_scan", { note_count: vaultIndex.notes.size });
16013
16136
  await updateEntitiesInStateDb();
16014
- await exportHubScores(vaultIndex, stateDb);
16137
+ const entitiesAfter = stateDb ? getAllEntitiesFromDb3(stateDb) : [];
16138
+ const entityDiff = computeEntityDiff(entitiesBefore, entitiesAfter);
16139
+ tracker.end({ entity_count: entitiesAfter.length, ...entityDiff });
16140
+ serverLog("watcher", `Entity scan: ${entitiesAfter.length} entities`);
16141
+ tracker.start("hub_scores", { entity_count: entityCount });
16142
+ const hubUpdated = await exportHubScores(vaultIndex, stateDb);
16143
+ tracker.end({ updated: hubUpdated ?? 0 });
16144
+ serverLog("watcher", `Hub scores: ${hubUpdated ?? 0} updated`);
16015
16145
  if (hasEmbeddingsIndex()) {
16146
+ tracker.start("note_embeddings", { files: batch.events.length });
16147
+ let embUpdated = 0;
16148
+ let embRemoved = 0;
16016
16149
  for (const event of batch.events) {
16017
16150
  try {
16018
16151
  if (event.type === "delete") {
16019
16152
  removeEmbedding(event.path);
16153
+ embRemoved++;
16020
16154
  } else if (event.path.endsWith(".md")) {
16021
16155
  const absPath = path29.join(vaultPath, event.path);
16022
16156
  await updateEmbedding(event.path, absPath);
16157
+ embUpdated++;
16023
16158
  }
16024
16159
  } catch {
16025
16160
  }
16026
16161
  }
16162
+ tracker.end({ updated: embUpdated, removed: embRemoved });
16163
+ serverLog("watcher", `Note embeddings: ${embUpdated} updated, ${embRemoved} removed`);
16164
+ } else {
16165
+ tracker.skip("note_embeddings", "not built");
16027
16166
  }
16028
16167
  if (hasEntityEmbeddingsIndex() && stateDb) {
16168
+ tracker.start("entity_embeddings", { files: batch.events.length });
16169
+ let entEmbUpdated = 0;
16029
16170
  try {
16030
16171
  const allEntities = getAllEntitiesFromDb3(stateDb);
16031
16172
  for (const event of batch.events) {
@@ -16038,28 +16179,58 @@ async function runPostIndexWork(index) {
16038
16179
  category: entity.category,
16039
16180
  aliases: entity.aliases
16040
16181
  }, vaultPath);
16182
+ entEmbUpdated++;
16041
16183
  }
16042
16184
  }
16043
16185
  } catch {
16044
16186
  }
16187
+ tracker.end({ updated: entEmbUpdated });
16188
+ serverLog("watcher", `Entity embeddings: ${entEmbUpdated} updated`);
16189
+ } else {
16190
+ tracker.skip("entity_embeddings", !stateDb ? "no stateDb" : "not built");
16045
16191
  }
16046
16192
  if (stateDb) {
16193
+ tracker.start("index_cache", { note_count: vaultIndex.notes.size });
16047
16194
  try {
16048
16195
  saveVaultIndexToCache(stateDb, vaultIndex);
16196
+ tracker.end({ saved: true });
16197
+ serverLog("watcher", "Index cache saved");
16049
16198
  } catch (err) {
16199
+ tracker.end({ saved: false, error: err instanceof Error ? err.message : String(err) });
16050
16200
  serverLog("index", `Failed to update index cache: ${err instanceof Error ? err.message : err}`, "error");
16051
16201
  }
16202
+ } else {
16203
+ tracker.skip("index_cache", "no stateDb");
16052
16204
  }
16205
+ tracker.start("task_cache", { files: batch.events.length });
16206
+ let taskUpdated = 0;
16207
+ let taskRemoved = 0;
16053
16208
  for (const event of batch.events) {
16054
16209
  try {
16055
16210
  if (event.type === "delete") {
16056
16211
  removeTaskCacheForFile(event.path);
16212
+ taskRemoved++;
16057
16213
  } else if (event.path.endsWith(".md")) {
16058
16214
  await updateTaskCacheForFile(vaultPath, event.path);
16215
+ taskUpdated++;
16059
16216
  }
16060
16217
  } catch {
16061
16218
  }
16062
16219
  }
16220
+ tracker.end({ updated: taskUpdated, removed: taskRemoved });
16221
+ serverLog("watcher", `Task cache: ${taskUpdated} updated, ${taskRemoved} removed`);
16222
+ const duration = Date.now() - batchStart;
16223
+ if (stateDb) {
16224
+ recordIndexEvent(stateDb, {
16225
+ trigger: "watcher",
16226
+ duration_ms: duration,
16227
+ note_count: vaultIndex.notes.size,
16228
+ files_changed: batch.events.length,
16229
+ changed_paths: changedPaths,
16230
+ steps: tracker.steps
16231
+ });
16232
+ }
16233
+ serverLog("watcher", `Batch complete: ${batch.events.length} files, ${duration}ms, ${tracker.steps.length} steps`);
16063
16234
  } catch (err) {
16064
16235
  setIndexState("error");
16065
16236
  setIndexError(err instanceof Error ? err : new Error(String(err)));
@@ -16071,7 +16242,8 @@ async function runPostIndexWork(index) {
16071
16242
  success: false,
16072
16243
  files_changed: batch.events.length,
16073
16244
  changed_paths: changedPaths,
16074
- error: err instanceof Error ? err.message : String(err)
16245
+ error: err instanceof Error ? err.message : String(err),
16246
+ steps: tracker.steps
16075
16247
  });
16076
16248
  }
16077
16249
  serverLog("watcher", `Failed to rebuild index: ${err instanceof Error ? err.message : err}`, "error");
@@ -16087,7 +16259,10 @@ async function runPostIndexWork(index) {
16087
16259
  }
16088
16260
  });
16089
16261
  watcher.start();
16262
+ serverLog("watcher", "File watcher started");
16090
16263
  }
16264
+ const postDuration = Date.now() - postStart;
16265
+ serverLog("server", `Post-index work complete in ${postDuration}ms`);
16091
16266
  }
16092
16267
  if (process.argv.includes("--init-semantic")) {
16093
16268
  (async () => {
package/package.json CHANGED
@@ -1,84 +1,84 @@
1
- {
2
- "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.0.29",
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
- "type": "module",
6
- "main": "dist/index.js",
7
- "bin": {
8
- "flywheel-memory": "dist/index.js"
9
- },
10
- "repository": {
11
- "type": "git",
12
- "url": "git+https://github.com/velvetmonkey/flywheel-memory.git",
13
- "directory": "packages/mcp-server"
14
- },
15
- "bugs": {
16
- "url": "https://github.com/velvetmonkey/flywheel-memory/issues"
17
- },
18
- "homepage": "https://github.com/velvetmonkey/flywheel-memory#readme",
19
- "author": "velvetmonkey",
20
- "keywords": [
21
- "mcp",
22
- "mcp-server",
23
- "obsidian",
24
- "pkm",
25
- "markdown",
26
- "knowledge-graph",
27
- "wikilinks",
28
- "backlinks",
29
- "vault",
30
- "claude",
31
- "claude-code",
32
- "local-first",
33
- "daily-notes",
34
- "zettelkasten"
35
- ],
36
- "scripts": {
37
- "build": "npx esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --packages=external && chmod +x dist/index.js",
38
- "dev": "npx esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --packages=external --watch",
39
- "test": "vitest run",
40
- "test:watch": "vitest",
41
- "test:read": "vitest run test/read/",
42
- "test:write": "vitest run test/write/",
43
- "test:security": "vitest run test/write/security/",
44
- "test:stress": "vitest run test/write/stress/ test/write/battle-hardening/",
45
- "test:coverage": "vitest run --coverage",
46
- "test:ci": "vitest run --reporter=github-actions",
47
- "lint": "tsc --noEmit",
48
- "clean": "rm -rf dist",
49
- "prepublishOnly": "npm run build"
50
- },
51
- "dependencies": {
52
- "@modelcontextprotocol/sdk": "^1.25.1",
53
- "@velvetmonkey/vault-core": "^2.0.29",
54
- "better-sqlite3": "^11.0.0",
55
- "chokidar": "^4.0.0",
56
- "gray-matter": "^4.0.3",
57
- "simple-git": "^3.22.0",
58
- "zod": "^3.22.4",
59
- "@huggingface/transformers": "^3.8.1"
60
- },
61
- "devDependencies": {
62
- "@types/better-sqlite3": "^7.6.0",
63
- "@types/node": "^20.10.0",
64
- "@vitest/coverage-v8": "^2.0.0",
65
- "esbuild": "^0.24.0",
66
- "fast-check": "^3.15.0",
67
- "mcp-testing-kit": "^0.2.0",
68
- "tsx": "^4.19.0",
69
- "typescript": "^5.3.2",
70
- "vitest": "^2.0.0"
71
- },
72
- "engines": {
73
- "node": ">=18.0.0"
74
- },
75
- "license": "AGPL-3.0-only",
76
- "files": [
77
- "dist",
78
- "README.md",
79
- "LICENSE"
80
- ],
81
- "publishConfig": {
82
- "access": "public"
83
- }
84
- }
1
+ {
2
+ "name": "@velvetmonkey/flywheel-memory",
3
+ "version": "2.0.31",
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
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "flywheel-memory": "dist/index.js"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/velvetmonkey/flywheel-memory.git",
13
+ "directory": "packages/mcp-server"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/velvetmonkey/flywheel-memory/issues"
17
+ },
18
+ "homepage": "https://github.com/velvetmonkey/flywheel-memory#readme",
19
+ "author": "velvetmonkey",
20
+ "keywords": [
21
+ "mcp",
22
+ "mcp-server",
23
+ "obsidian",
24
+ "pkm",
25
+ "markdown",
26
+ "knowledge-graph",
27
+ "wikilinks",
28
+ "backlinks",
29
+ "vault",
30
+ "claude",
31
+ "claude-code",
32
+ "local-first",
33
+ "daily-notes",
34
+ "zettelkasten"
35
+ ],
36
+ "scripts": {
37
+ "build": "npx esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --packages=external && chmod +x dist/index.js",
38
+ "dev": "npx esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --packages=external --watch",
39
+ "test": "vitest run",
40
+ "test:watch": "vitest",
41
+ "test:read": "vitest run test/read/",
42
+ "test:write": "vitest run test/write/",
43
+ "test:security": "vitest run test/write/security/",
44
+ "test:stress": "vitest run test/write/stress/ test/write/battle-hardening/",
45
+ "test:coverage": "vitest run --coverage",
46
+ "test:ci": "vitest run --reporter=github-actions",
47
+ "lint": "tsc --noEmit",
48
+ "clean": "rm -rf dist",
49
+ "prepublishOnly": "npm run build"
50
+ },
51
+ "dependencies": {
52
+ "@modelcontextprotocol/sdk": "^1.25.1",
53
+ "@velvetmonkey/vault-core": "^2.0.31",
54
+ "better-sqlite3": "^11.0.0",
55
+ "chokidar": "^4.0.0",
56
+ "gray-matter": "^4.0.3",
57
+ "simple-git": "^3.22.0",
58
+ "zod": "^3.22.4",
59
+ "@huggingface/transformers": "^3.8.1"
60
+ },
61
+ "devDependencies": {
62
+ "@types/better-sqlite3": "^7.6.0",
63
+ "@types/node": "^20.10.0",
64
+ "@vitest/coverage-v8": "^2.0.0",
65
+ "esbuild": "^0.24.0",
66
+ "fast-check": "^3.15.0",
67
+ "mcp-testing-kit": "^0.2.0",
68
+ "tsx": "^4.19.0",
69
+ "typescript": "^5.3.2",
70
+ "vitest": "^2.0.0"
71
+ },
72
+ "engines": {
73
+ "node": ">=18.0.0"
74
+ },
75
+ "license": "AGPL-3.0-only",
76
+ "files": [
77
+ "dist",
78
+ "README.md",
79
+ "LICENSE"
80
+ ],
81
+ "publishConfig": {
82
+ "access": "public"
83
+ }
84
+ }