@velvetmonkey/flywheel-memory 2.0.17 → 2.0.19

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 +2490 -967
  2. package/package.json +4 -3
package/dist/index.js CHANGED
@@ -23,8 +23,8 @@ var init_constants = __esm({
23
23
  });
24
24
 
25
25
  // src/core/write/writer.ts
26
- import fs15 from "fs/promises";
27
- import path15 from "path";
26
+ import fs17 from "fs/promises";
27
+ import path16 from "path";
28
28
  import matter5 from "gray-matter";
29
29
  function isSensitivePath(filePath) {
30
30
  const normalizedPath = filePath.replace(/\\/g, "/");
@@ -345,8 +345,8 @@ function validatePath(vaultPath2, notePath) {
345
345
  if (notePath.startsWith("\\")) {
346
346
  return false;
347
347
  }
348
- const resolvedVault = path15.resolve(vaultPath2);
349
- const resolvedNote = path15.resolve(vaultPath2, notePath);
348
+ const resolvedVault = path16.resolve(vaultPath2);
349
+ const resolvedNote = path16.resolve(vaultPath2, notePath);
350
350
  return resolvedNote.startsWith(resolvedVault);
351
351
  }
352
352
  async function validatePathSecure(vaultPath2, notePath) {
@@ -374,8 +374,8 @@ async function validatePathSecure(vaultPath2, notePath) {
374
374
  reason: "Path traversal not allowed"
375
375
  };
376
376
  }
377
- const resolvedVault = path15.resolve(vaultPath2);
378
- const resolvedNote = path15.resolve(vaultPath2, notePath);
377
+ const resolvedVault = path16.resolve(vaultPath2);
378
+ const resolvedNote = path16.resolve(vaultPath2, notePath);
379
379
  if (!resolvedNote.startsWith(resolvedVault)) {
380
380
  return {
381
381
  valid: false,
@@ -389,18 +389,18 @@ async function validatePathSecure(vaultPath2, notePath) {
389
389
  };
390
390
  }
391
391
  try {
392
- const fullPath = path15.join(vaultPath2, notePath);
392
+ const fullPath = path16.join(vaultPath2, notePath);
393
393
  try {
394
- await fs15.access(fullPath);
395
- const realPath = await fs15.realpath(fullPath);
396
- const realVaultPath = await fs15.realpath(vaultPath2);
394
+ await fs17.access(fullPath);
395
+ const realPath = await fs17.realpath(fullPath);
396
+ const realVaultPath = await fs17.realpath(vaultPath2);
397
397
  if (!realPath.startsWith(realVaultPath)) {
398
398
  return {
399
399
  valid: false,
400
400
  reason: "Symlink target is outside vault"
401
401
  };
402
402
  }
403
- const relativePath = path15.relative(realVaultPath, realPath);
403
+ const relativePath = path16.relative(realVaultPath, realPath);
404
404
  if (isSensitivePath(relativePath)) {
405
405
  return {
406
406
  valid: false,
@@ -408,11 +408,11 @@ async function validatePathSecure(vaultPath2, notePath) {
408
408
  };
409
409
  }
410
410
  } catch {
411
- const parentDir = path15.dirname(fullPath);
411
+ const parentDir = path16.dirname(fullPath);
412
412
  try {
413
- await fs15.access(parentDir);
414
- const realParentPath = await fs15.realpath(parentDir);
415
- const realVaultPath = await fs15.realpath(vaultPath2);
413
+ await fs17.access(parentDir);
414
+ const realParentPath = await fs17.realpath(parentDir);
415
+ const realVaultPath = await fs17.realpath(vaultPath2);
416
416
  if (!realParentPath.startsWith(realVaultPath)) {
417
417
  return {
418
418
  valid: false,
@@ -434,8 +434,8 @@ async function readVaultFile(vaultPath2, notePath) {
434
434
  if (!validatePath(vaultPath2, notePath)) {
435
435
  throw new Error("Invalid path: path traversal not allowed");
436
436
  }
437
- const fullPath = path15.join(vaultPath2, notePath);
438
- const rawContent = await fs15.readFile(fullPath, "utf-8");
437
+ const fullPath = path16.join(vaultPath2, notePath);
438
+ const rawContent = await fs17.readFile(fullPath, "utf-8");
439
439
  const lineEnding = detectLineEnding(rawContent);
440
440
  const normalizedContent = normalizeLineEndings(rawContent);
441
441
  const parsed = matter5(normalizedContent);
@@ -483,11 +483,11 @@ async function writeVaultFile(vaultPath2, notePath, content, frontmatter, lineEn
483
483
  if (!validation.valid) {
484
484
  throw new Error(`Invalid path: ${validation.reason}`);
485
485
  }
486
- const fullPath = path15.join(vaultPath2, notePath);
486
+ const fullPath = path16.join(vaultPath2, notePath);
487
487
  let output = matter5.stringify(content, frontmatter);
488
488
  output = normalizeTrailingNewline(output);
489
489
  output = convertLineEndings(output, lineEnding);
490
- await fs15.writeFile(fullPath, output, "utf-8");
490
+ await fs17.writeFile(fullPath, output, "utf-8");
491
491
  }
492
492
  function removeFromSection(content, section, pattern, mode = "first", useRegex = false) {
493
493
  const lines = content.split("\n");
@@ -711,8 +711,8 @@ function createContext(variables = {}) {
711
711
  }
712
712
  };
713
713
  }
714
- function resolvePath(obj, path25) {
715
- const parts = path25.split(".");
714
+ function resolvePath(obj, path28) {
715
+ const parts = path28.split(".");
716
716
  let current = obj;
717
717
  for (const part of parts) {
718
718
  if (current === void 0 || current === null) {
@@ -1150,8 +1150,8 @@ __export(conditions_exports, {
1150
1150
  evaluateCondition: () => evaluateCondition,
1151
1151
  shouldStepExecute: () => shouldStepExecute
1152
1152
  });
1153
- import fs21 from "fs/promises";
1154
- import path21 from "path";
1153
+ import fs23 from "fs/promises";
1154
+ import path22 from "path";
1155
1155
  async function evaluateCondition(condition, vaultPath2, context) {
1156
1156
  const interpolatedPath = condition.path ? interpolate(condition.path, context) : void 0;
1157
1157
  const interpolatedSection = condition.section ? interpolate(condition.section, context) : void 0;
@@ -1204,9 +1204,9 @@ async function evaluateCondition(condition, vaultPath2, context) {
1204
1204
  }
1205
1205
  }
1206
1206
  async function evaluateFileExists(vaultPath2, notePath, expectExists) {
1207
- const fullPath = path21.join(vaultPath2, notePath);
1207
+ const fullPath = path22.join(vaultPath2, notePath);
1208
1208
  try {
1209
- await fs21.access(fullPath);
1209
+ await fs23.access(fullPath);
1210
1210
  return {
1211
1211
  met: expectExists,
1212
1212
  reason: expectExists ? `File exists: ${notePath}` : `File exists (expected not to): ${notePath}`
@@ -1219,9 +1219,9 @@ async function evaluateFileExists(vaultPath2, notePath, expectExists) {
1219
1219
  }
1220
1220
  }
1221
1221
  async function evaluateSectionExists(vaultPath2, notePath, sectionName, expectExists) {
1222
- const fullPath = path21.join(vaultPath2, notePath);
1222
+ const fullPath = path22.join(vaultPath2, notePath);
1223
1223
  try {
1224
- await fs21.access(fullPath);
1224
+ await fs23.access(fullPath);
1225
1225
  } catch {
1226
1226
  return {
1227
1227
  met: !expectExists,
@@ -1250,9 +1250,9 @@ async function evaluateSectionExists(vaultPath2, notePath, sectionName, expectEx
1250
1250
  }
1251
1251
  }
1252
1252
  async function evaluateFrontmatterExists(vaultPath2, notePath, fieldName, expectExists) {
1253
- const fullPath = path21.join(vaultPath2, notePath);
1253
+ const fullPath = path22.join(vaultPath2, notePath);
1254
1254
  try {
1255
- await fs21.access(fullPath);
1255
+ await fs23.access(fullPath);
1256
1256
  } catch {
1257
1257
  return {
1258
1258
  met: !expectExists,
@@ -1281,9 +1281,9 @@ async function evaluateFrontmatterExists(vaultPath2, notePath, fieldName, expect
1281
1281
  }
1282
1282
  }
1283
1283
  async function evaluateFrontmatterEquals(vaultPath2, notePath, fieldName, expectedValue) {
1284
- const fullPath = path21.join(vaultPath2, notePath);
1284
+ const fullPath = path22.join(vaultPath2, notePath);
1285
1285
  try {
1286
- await fs21.access(fullPath);
1286
+ await fs23.access(fullPath);
1287
1287
  } catch {
1288
1288
  return {
1289
1289
  met: false,
@@ -1424,6 +1424,7 @@ var init_taskHelpers = __esm({
1424
1424
  });
1425
1425
 
1426
1426
  // src/index.ts
1427
+ import * as path27 from "path";
1427
1428
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1428
1429
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1429
1430
 
@@ -1689,8 +1690,8 @@ function updateIndexProgress(parsed, total) {
1689
1690
  function normalizeTarget(target) {
1690
1691
  return target.toLowerCase().replace(/\.md$/, "");
1691
1692
  }
1692
- function normalizeNotePath(path25) {
1693
- return path25.toLowerCase().replace(/\.md$/, "");
1693
+ function normalizeNotePath(path28) {
1694
+ return path28.toLowerCase().replace(/\.md$/, "");
1694
1695
  }
1695
1696
  async function buildVaultIndex(vaultPath2, options = {}) {
1696
1697
  const { timeoutMs = DEFAULT_TIMEOUT_MS, onProgress } = options;
@@ -1884,7 +1885,7 @@ function findSimilarEntity(index, target) {
1884
1885
  }
1885
1886
  const maxDist = normalizedLen <= 10 ? 1 : 2;
1886
1887
  let bestMatch;
1887
- for (const [entity, path25] of index.entities) {
1888
+ for (const [entity, path28] of index.entities) {
1888
1889
  const lenDiff = Math.abs(entity.length - normalizedLen);
1889
1890
  if (lenDiff > maxDist) {
1890
1891
  continue;
@@ -1892,7 +1893,7 @@ function findSimilarEntity(index, target) {
1892
1893
  const dist = levenshteinDistance(normalized, entity);
1893
1894
  if (dist > 0 && dist <= maxDist) {
1894
1895
  if (!bestMatch || dist < bestMatch.distance) {
1895
- bestMatch = { path: path25, entity, distance: dist };
1896
+ bestMatch = { path: path28, entity, distance: dist };
1896
1897
  if (dist === 1) {
1897
1898
  return bestMatch;
1898
1899
  }
@@ -2354,30 +2355,30 @@ var EventQueue = class {
2354
2355
  * Add a new event to the queue
2355
2356
  */
2356
2357
  push(type, rawPath) {
2357
- const path25 = normalizePath(rawPath);
2358
+ const path28 = normalizePath(rawPath);
2358
2359
  const now = Date.now();
2359
2360
  const event = {
2360
2361
  type,
2361
- path: path25,
2362
+ path: path28,
2362
2363
  timestamp: now
2363
2364
  };
2364
- let pending = this.pending.get(path25);
2365
+ let pending = this.pending.get(path28);
2365
2366
  if (!pending) {
2366
2367
  pending = {
2367
2368
  events: [],
2368
2369
  timer: null,
2369
2370
  lastEvent: now
2370
2371
  };
2371
- this.pending.set(path25, pending);
2372
+ this.pending.set(path28, pending);
2372
2373
  }
2373
2374
  pending.events.push(event);
2374
2375
  pending.lastEvent = now;
2375
- console.error(`[flywheel] QUEUE: pushed ${type} for ${path25}, pending=${this.pending.size}`);
2376
+ console.error(`[flywheel] QUEUE: pushed ${type} for ${path28}, pending=${this.pending.size}`);
2376
2377
  if (pending.timer) {
2377
2378
  clearTimeout(pending.timer);
2378
2379
  }
2379
2380
  pending.timer = setTimeout(() => {
2380
- this.flushPath(path25);
2381
+ this.flushPath(path28);
2381
2382
  }, this.config.debounceMs);
2382
2383
  if (this.pending.size >= this.config.batchSize) {
2383
2384
  this.flush();
@@ -2398,10 +2399,10 @@ var EventQueue = class {
2398
2399
  /**
2399
2400
  * Flush a single path's events
2400
2401
  */
2401
- flushPath(path25) {
2402
- const pending = this.pending.get(path25);
2402
+ flushPath(path28) {
2403
+ const pending = this.pending.get(path28);
2403
2404
  if (!pending || pending.events.length === 0) return;
2404
- console.error(`[flywheel] QUEUE: flushing ${path25}, events=${pending.events.length}`);
2405
+ console.error(`[flywheel] QUEUE: flushing ${path28}, events=${pending.events.length}`);
2405
2406
  if (pending.timer) {
2406
2407
  clearTimeout(pending.timer);
2407
2408
  pending.timer = null;
@@ -2410,7 +2411,7 @@ var EventQueue = class {
2410
2411
  if (coalescedType) {
2411
2412
  const coalesced = {
2412
2413
  type: coalescedType,
2413
- path: path25,
2414
+ path: path28,
2414
2415
  originalEvents: [...pending.events]
2415
2416
  };
2416
2417
  this.onBatch({
@@ -2418,7 +2419,7 @@ var EventQueue = class {
2418
2419
  timestamp: Date.now()
2419
2420
  });
2420
2421
  }
2421
- this.pending.delete(path25);
2422
+ this.pending.delete(path28);
2422
2423
  }
2423
2424
  /**
2424
2425
  * Flush all pending events
@@ -2430,7 +2431,7 @@ var EventQueue = class {
2430
2431
  }
2431
2432
  if (this.pending.size === 0) return;
2432
2433
  const events = [];
2433
- for (const [path25, pending] of this.pending) {
2434
+ for (const [path28, pending] of this.pending) {
2434
2435
  if (pending.timer) {
2435
2436
  clearTimeout(pending.timer);
2436
2437
  }
@@ -2438,7 +2439,7 @@ var EventQueue = class {
2438
2439
  if (coalescedType) {
2439
2440
  events.push({
2440
2441
  type: coalescedType,
2441
- path: path25,
2442
+ path: path28,
2442
2443
  originalEvents: [...pending.events]
2443
2444
  });
2444
2445
  }
@@ -2587,31 +2588,31 @@ function createVaultWatcher(options) {
2587
2588
  usePolling: config.usePolling,
2588
2589
  interval: config.usePolling ? config.pollInterval : void 0
2589
2590
  });
2590
- watcher.on("add", (path25) => {
2591
- console.error(`[flywheel] RAW EVENT: add ${path25}`);
2592
- if (shouldWatch(path25, vaultPath2)) {
2593
- console.error(`[flywheel] ACCEPTED: add ${path25}`);
2594
- eventQueue.push("add", path25);
2591
+ watcher.on("add", (path28) => {
2592
+ console.error(`[flywheel] RAW EVENT: add ${path28}`);
2593
+ if (shouldWatch(path28, vaultPath2)) {
2594
+ console.error(`[flywheel] ACCEPTED: add ${path28}`);
2595
+ eventQueue.push("add", path28);
2595
2596
  } else {
2596
- console.error(`[flywheel] FILTERED: add ${path25}`);
2597
+ console.error(`[flywheel] FILTERED: add ${path28}`);
2597
2598
  }
2598
2599
  });
2599
- watcher.on("change", (path25) => {
2600
- console.error(`[flywheel] RAW EVENT: change ${path25}`);
2601
- if (shouldWatch(path25, vaultPath2)) {
2602
- console.error(`[flywheel] ACCEPTED: change ${path25}`);
2603
- eventQueue.push("change", path25);
2600
+ watcher.on("change", (path28) => {
2601
+ console.error(`[flywheel] RAW EVENT: change ${path28}`);
2602
+ if (shouldWatch(path28, vaultPath2)) {
2603
+ console.error(`[flywheel] ACCEPTED: change ${path28}`);
2604
+ eventQueue.push("change", path28);
2604
2605
  } else {
2605
- console.error(`[flywheel] FILTERED: change ${path25}`);
2606
+ console.error(`[flywheel] FILTERED: change ${path28}`);
2606
2607
  }
2607
2608
  });
2608
- watcher.on("unlink", (path25) => {
2609
- console.error(`[flywheel] RAW EVENT: unlink ${path25}`);
2610
- if (shouldWatch(path25, vaultPath2)) {
2611
- console.error(`[flywheel] ACCEPTED: unlink ${path25}`);
2612
- eventQueue.push("unlink", path25);
2609
+ watcher.on("unlink", (path28) => {
2610
+ console.error(`[flywheel] RAW EVENT: unlink ${path28}`);
2611
+ if (shouldWatch(path28, vaultPath2)) {
2612
+ console.error(`[flywheel] ACCEPTED: unlink ${path28}`);
2613
+ eventQueue.push("unlink", path28);
2613
2614
  } else {
2614
- console.error(`[flywheel] FILTERED: unlink ${path25}`);
2615
+ console.error(`[flywheel] FILTERED: unlink ${path28}`);
2615
2616
  }
2616
2617
  });
2617
2618
  watcher.on("ready", () => {
@@ -2901,10 +2902,10 @@ function getAllFeedbackBoosts(stateDb2, folder) {
2901
2902
  for (const row of globalRows) {
2902
2903
  let accuracy;
2903
2904
  let sampleCount;
2904
- const fs25 = folderStats?.get(row.entity);
2905
- if (fs25 && fs25.count >= FEEDBACK_BOOST_MIN_SAMPLES) {
2906
- accuracy = fs25.accuracy;
2907
- sampleCount = fs25.count;
2905
+ const fs28 = folderStats?.get(row.entity);
2906
+ if (fs28 && fs28.count >= FEEDBACK_BOOST_MIN_SAMPLES) {
2907
+ accuracy = fs28.accuracy;
2908
+ sampleCount = fs28.count;
2908
2909
  } else {
2909
2910
  accuracy = row.correct_count / row.total;
2910
2911
  sampleCount = row.total;
@@ -5271,11 +5272,228 @@ function getFTS5State() {
5271
5272
  return { ...state };
5272
5273
  }
5273
5274
 
5275
+ // src/core/read/embeddings.ts
5276
+ import * as crypto from "crypto";
5277
+ import * as fs7 from "fs";
5278
+ var MODEL_ID = "Xenova/all-MiniLM-L6-v2";
5279
+ var EXCLUDED_DIRS3 = /* @__PURE__ */ new Set([
5280
+ ".obsidian",
5281
+ ".trash",
5282
+ ".git",
5283
+ "node_modules",
5284
+ "templates",
5285
+ ".claude",
5286
+ ".flywheel"
5287
+ ]);
5288
+ var MAX_FILE_SIZE2 = 5 * 1024 * 1024;
5289
+ var db2 = null;
5290
+ var pipeline = null;
5291
+ var initPromise = null;
5292
+ function setEmbeddingsDatabase(database) {
5293
+ db2 = database;
5294
+ }
5295
+ async function initEmbeddings() {
5296
+ if (pipeline) return;
5297
+ if (initPromise) return initPromise;
5298
+ initPromise = (async () => {
5299
+ try {
5300
+ const transformers = await Function("specifier", "return import(specifier)")("@huggingface/transformers");
5301
+ pipeline = await transformers.pipeline("feature-extraction", MODEL_ID, {
5302
+ dtype: "fp32"
5303
+ });
5304
+ } catch (err) {
5305
+ initPromise = null;
5306
+ if (err instanceof Error && (err.message.includes("Cannot find package") || err.message.includes("MODULE_NOT_FOUND") || err.message.includes("Cannot find module") || err.message.includes("ERR_MODULE_NOT_FOUND"))) {
5307
+ throw new Error(
5308
+ "Semantic search requires @huggingface/transformers. Install it with: npm install @huggingface/transformers"
5309
+ );
5310
+ }
5311
+ throw err;
5312
+ }
5313
+ })();
5314
+ return initPromise;
5315
+ }
5316
+ async function embedText(text) {
5317
+ await initEmbeddings();
5318
+ const truncated = text.slice(0, 2e3);
5319
+ const result = await pipeline(truncated, { pooling: "mean", normalize: true });
5320
+ return new Float32Array(result.data);
5321
+ }
5322
+ function contentHash(content) {
5323
+ return crypto.createHash("md5").update(content).digest("hex");
5324
+ }
5325
+ function shouldIndexFile2(filePath) {
5326
+ const parts = filePath.split("/");
5327
+ return !parts.some((part) => EXCLUDED_DIRS3.has(part));
5328
+ }
5329
+ async function buildEmbeddingsIndex(vaultPath2, onProgress) {
5330
+ if (!db2) {
5331
+ throw new Error("Embeddings database not initialized. Call setEmbeddingsDatabase() first.");
5332
+ }
5333
+ await initEmbeddings();
5334
+ const files = await scanVault(vaultPath2);
5335
+ const indexable = files.filter((f) => shouldIndexFile2(f.path));
5336
+ const existingHashes = /* @__PURE__ */ new Map();
5337
+ const rows = db2.prepare("SELECT path, content_hash FROM note_embeddings").all();
5338
+ for (const row of rows) {
5339
+ existingHashes.set(row.path, row.content_hash);
5340
+ }
5341
+ const upsert = db2.prepare(`
5342
+ INSERT OR REPLACE INTO note_embeddings (path, embedding, content_hash, model, updated_at)
5343
+ VALUES (?, ?, ?, ?, ?)
5344
+ `);
5345
+ const progress = { total: indexable.length, current: 0, skipped: 0 };
5346
+ for (const file of indexable) {
5347
+ progress.current++;
5348
+ try {
5349
+ const stats = fs7.statSync(file.absolutePath);
5350
+ if (stats.size > MAX_FILE_SIZE2) {
5351
+ progress.skipped++;
5352
+ continue;
5353
+ }
5354
+ const content = fs7.readFileSync(file.absolutePath, "utf-8");
5355
+ const hash = contentHash(content);
5356
+ if (existingHashes.get(file.path) === hash) {
5357
+ progress.skipped++;
5358
+ if (onProgress) onProgress(progress);
5359
+ continue;
5360
+ }
5361
+ const embedding = await embedText(content);
5362
+ const buf = Buffer.from(embedding.buffer, embedding.byteOffset, embedding.byteLength);
5363
+ upsert.run(file.path, buf, hash, MODEL_ID, Date.now());
5364
+ } catch {
5365
+ progress.skipped++;
5366
+ }
5367
+ if (onProgress) onProgress(progress);
5368
+ }
5369
+ const currentPaths = new Set(indexable.map((f) => f.path));
5370
+ const deleteStmt = db2.prepare("DELETE FROM note_embeddings WHERE path = ?");
5371
+ for (const existingPath of existingHashes.keys()) {
5372
+ if (!currentPaths.has(existingPath)) {
5373
+ deleteStmt.run(existingPath);
5374
+ }
5375
+ }
5376
+ console.error(`[Semantic] Indexed ${progress.current - progress.skipped} notes, skipped ${progress.skipped}`);
5377
+ return progress;
5378
+ }
5379
+ async function updateEmbedding(notePath, absolutePath) {
5380
+ if (!db2) return;
5381
+ try {
5382
+ const content = fs7.readFileSync(absolutePath, "utf-8");
5383
+ const hash = contentHash(content);
5384
+ const existing = db2.prepare("SELECT content_hash FROM note_embeddings WHERE path = ?").get(notePath);
5385
+ if (existing?.content_hash === hash) return;
5386
+ const embedding = await embedText(content);
5387
+ const buf = Buffer.from(embedding.buffer, embedding.byteOffset, embedding.byteLength);
5388
+ db2.prepare(`
5389
+ INSERT OR REPLACE INTO note_embeddings (path, embedding, content_hash, model, updated_at)
5390
+ VALUES (?, ?, ?, ?, ?)
5391
+ `).run(notePath, buf, hash, MODEL_ID, Date.now());
5392
+ } catch {
5393
+ }
5394
+ }
5395
+ function removeEmbedding(notePath) {
5396
+ if (!db2) return;
5397
+ db2.prepare("DELETE FROM note_embeddings WHERE path = ?").run(notePath);
5398
+ }
5399
+ function cosineSimilarity(a, b) {
5400
+ let dot = 0;
5401
+ let normA = 0;
5402
+ let normB = 0;
5403
+ for (let i = 0; i < a.length; i++) {
5404
+ dot += a[i] * b[i];
5405
+ normA += a[i] * a[i];
5406
+ normB += b[i] * b[i];
5407
+ }
5408
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
5409
+ if (denom === 0) return 0;
5410
+ return dot / denom;
5411
+ }
5412
+ async function semanticSearch(query, limit = 10) {
5413
+ if (!db2) {
5414
+ throw new Error("Embeddings database not initialized. Call setEmbeddingsDatabase() first.");
5415
+ }
5416
+ const queryEmbedding = await embedText(query);
5417
+ const rows = db2.prepare("SELECT path, embedding FROM note_embeddings").all();
5418
+ const scored = [];
5419
+ for (const row of rows) {
5420
+ const noteEmbedding = new Float32Array(
5421
+ row.embedding.buffer,
5422
+ row.embedding.byteOffset,
5423
+ row.embedding.byteLength / Float32Array.BYTES_PER_ELEMENT
5424
+ );
5425
+ const score = cosineSimilarity(queryEmbedding, noteEmbedding);
5426
+ const title = row.path.replace(/\.md$/, "").split("/").pop() || row.path;
5427
+ scored.push({ path: row.path, title, score: Math.round(score * 1e3) / 1e3 });
5428
+ }
5429
+ scored.sort((a, b) => b.score - a.score);
5430
+ return scored.slice(0, limit);
5431
+ }
5432
+ async function findSemanticallySimilar(sourcePath, limit = 10, excludePaths) {
5433
+ if (!db2) {
5434
+ throw new Error("Embeddings database not initialized. Call setEmbeddingsDatabase() first.");
5435
+ }
5436
+ const sourceRow = db2.prepare("SELECT embedding FROM note_embeddings WHERE path = ?").get(sourcePath);
5437
+ if (!sourceRow) {
5438
+ return [];
5439
+ }
5440
+ const sourceEmbedding = new Float32Array(
5441
+ sourceRow.embedding.buffer,
5442
+ sourceRow.embedding.byteOffset,
5443
+ sourceRow.embedding.byteLength / Float32Array.BYTES_PER_ELEMENT
5444
+ );
5445
+ const rows = db2.prepare("SELECT path, embedding FROM note_embeddings WHERE path != ?").all(sourcePath);
5446
+ const scored = [];
5447
+ for (const row of rows) {
5448
+ if (excludePaths?.has(row.path)) continue;
5449
+ const noteEmbedding = new Float32Array(
5450
+ row.embedding.buffer,
5451
+ row.embedding.byteOffset,
5452
+ row.embedding.byteLength / Float32Array.BYTES_PER_ELEMENT
5453
+ );
5454
+ const score = cosineSimilarity(sourceEmbedding, noteEmbedding);
5455
+ const title = row.path.replace(/\.md$/, "").split("/").pop() || row.path;
5456
+ scored.push({ path: row.path, title, score: Math.round(score * 1e3) / 1e3 });
5457
+ }
5458
+ scored.sort((a, b) => b.score - a.score);
5459
+ return scored.slice(0, limit);
5460
+ }
5461
+ function reciprocalRankFusion(...lists) {
5462
+ const k = 60;
5463
+ const scores = /* @__PURE__ */ new Map();
5464
+ for (const list of lists) {
5465
+ for (let rank = 0; rank < list.length; rank++) {
5466
+ const item = list[rank];
5467
+ const rrfScore = 1 / (k + rank + 1);
5468
+ scores.set(item.path, (scores.get(item.path) || 0) + rrfScore);
5469
+ }
5470
+ }
5471
+ return scores;
5472
+ }
5473
+ function hasEmbeddingsIndex() {
5474
+ if (!db2) return false;
5475
+ try {
5476
+ const row = db2.prepare("SELECT COUNT(*) as count FROM note_embeddings").get();
5477
+ return row.count > 0;
5478
+ } catch {
5479
+ return false;
5480
+ }
5481
+ }
5482
+ function getEmbeddingsCount() {
5483
+ if (!db2) return 0;
5484
+ try {
5485
+ const row = db2.prepare("SELECT COUNT(*) as count FROM note_embeddings").get();
5486
+ return row.count;
5487
+ } catch {
5488
+ return 0;
5489
+ }
5490
+ }
5491
+
5274
5492
  // src/index.ts
5275
- import { openStateDb, scanVaultEntities as scanVaultEntities3 } from "@velvetmonkey/vault-core";
5493
+ import { openStateDb, scanVaultEntities as scanVaultEntities3, getSessionId } from "@velvetmonkey/vault-core";
5276
5494
 
5277
5495
  // src/tools/read/graph.ts
5278
- import * as fs7 from "fs";
5496
+ import * as fs8 from "fs";
5279
5497
  import * as path8 from "path";
5280
5498
  import { z } from "zod";
5281
5499
 
@@ -5561,7 +5779,7 @@ function requireIndex() {
5561
5779
  async function getContext(vaultPath2, sourcePath, line, contextLines = 1) {
5562
5780
  try {
5563
5781
  const fullPath = path8.join(vaultPath2, sourcePath);
5564
- const content = await fs7.promises.readFile(fullPath, "utf-8");
5782
+ const content = await fs8.promises.readFile(fullPath, "utf-8");
5565
5783
  const lines = content.split("\n");
5566
5784
  const startLine = Math.max(0, line - 1 - contextLines);
5567
5785
  const endLine = Math.min(lines.length, line + contextLines);
@@ -5864,14 +6082,14 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
5864
6082
  };
5865
6083
  function findSimilarEntity2(target, entities) {
5866
6084
  const targetLower = target.toLowerCase();
5867
- for (const [name, path25] of entities) {
6085
+ for (const [name, path28] of entities) {
5868
6086
  if (name.startsWith(targetLower) || targetLower.startsWith(name)) {
5869
- return path25;
6087
+ return path28;
5870
6088
  }
5871
6089
  }
5872
- for (const [name, path25] of entities) {
6090
+ for (const [name, path28] of entities) {
5873
6091
  if (name.includes(targetLower) || targetLower.includes(name)) {
5874
- return path25;
6092
+ return path28;
5875
6093
  }
5876
6094
  }
5877
6095
  return void 0;
@@ -5953,7 +6171,7 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
5953
6171
  }
5954
6172
 
5955
6173
  // src/tools/read/health.ts
5956
- import * as fs8 from "fs";
6174
+ import * as fs9 from "fs";
5957
6175
  import { z as z3 } from "zod";
5958
6176
 
5959
6177
  // src/tools/read/periodic.ts
@@ -6186,9 +6404,84 @@ function getActivitySummary(index, days) {
6186
6404
  };
6187
6405
  }
6188
6406
 
6407
+ // src/core/shared/indexActivity.ts
6408
+ function recordIndexEvent(stateDb2, event) {
6409
+ stateDb2.db.prepare(
6410
+ `INSERT INTO index_events (timestamp, trigger, duration_ms, success, note_count, files_changed, changed_paths, error)
6411
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
6412
+ ).run(
6413
+ Date.now(),
6414
+ event.trigger,
6415
+ event.duration_ms,
6416
+ event.success !== false ? 1 : 0,
6417
+ event.note_count ?? null,
6418
+ event.files_changed ?? null,
6419
+ event.changed_paths ? JSON.stringify(event.changed_paths) : null,
6420
+ event.error ?? null
6421
+ );
6422
+ }
6423
+ function rowToEvent(row) {
6424
+ return {
6425
+ id: row.id,
6426
+ timestamp: row.timestamp,
6427
+ trigger: row.trigger,
6428
+ duration_ms: row.duration_ms,
6429
+ success: row.success === 1,
6430
+ note_count: row.note_count,
6431
+ files_changed: row.files_changed,
6432
+ changed_paths: row.changed_paths ? JSON.parse(row.changed_paths) : null,
6433
+ error: row.error
6434
+ };
6435
+ }
6436
+ function getRecentIndexEvents(stateDb2, limit = 20) {
6437
+ const rows = stateDb2.db.prepare(
6438
+ "SELECT * FROM index_events ORDER BY timestamp DESC LIMIT ?"
6439
+ ).all(limit);
6440
+ return rows.map(rowToEvent);
6441
+ }
6442
+ function getIndexActivitySummary(stateDb2) {
6443
+ const now = Date.now();
6444
+ const todayStart = /* @__PURE__ */ new Date();
6445
+ todayStart.setHours(0, 0, 0, 0);
6446
+ const last24h = now - 24 * 60 * 60 * 1e3;
6447
+ const totalRow = stateDb2.db.prepare(
6448
+ "SELECT COUNT(*) as count FROM index_events"
6449
+ ).get();
6450
+ const todayRow = stateDb2.db.prepare(
6451
+ "SELECT COUNT(*) as count FROM index_events WHERE timestamp >= ?"
6452
+ ).get(todayStart.getTime());
6453
+ const last24hRow = stateDb2.db.prepare(
6454
+ "SELECT COUNT(*) as count FROM index_events WHERE timestamp >= ?"
6455
+ ).get(last24h);
6456
+ const avgRow = stateDb2.db.prepare(
6457
+ "SELECT AVG(duration_ms) as avg_ms FROM index_events WHERE success = 1"
6458
+ ).get();
6459
+ const failureRow = stateDb2.db.prepare(
6460
+ "SELECT COUNT(*) as count FROM index_events WHERE success = 0"
6461
+ ).get();
6462
+ const lastRow = stateDb2.db.prepare(
6463
+ "SELECT * FROM index_events ORDER BY timestamp DESC LIMIT 1"
6464
+ ).get();
6465
+ return {
6466
+ total_rebuilds: totalRow.count,
6467
+ last_rebuild: lastRow ? rowToEvent(lastRow) : null,
6468
+ rebuilds_today: todayRow.count,
6469
+ rebuilds_last_24h: last24hRow.count,
6470
+ avg_duration_ms: Math.round(avgRow.avg_ms ?? 0),
6471
+ failure_count: failureRow.count
6472
+ };
6473
+ }
6474
+ function purgeOldIndexEvents(stateDb2, retentionDays = 90) {
6475
+ const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1e3;
6476
+ const result = stateDb2.db.prepare(
6477
+ "DELETE FROM index_events WHERE timestamp < ?"
6478
+ ).run(cutoff);
6479
+ return result.changes;
6480
+ }
6481
+
6189
6482
  // src/tools/read/health.ts
6190
6483
  var STALE_THRESHOLD_SECONDS = 300;
6191
- function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () => ({})) {
6484
+ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () => ({}), getStateDb = () => null) {
6192
6485
  const IndexProgressSchema = z3.object({
6193
6486
  parsed: z3.coerce.number().describe("Number of files parsed so far"),
6194
6487
  total: z3.coerce.number().describe("Total number of files to parse")
@@ -6216,6 +6509,12 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
6216
6509
  tag_count: z3.coerce.number().describe("Number of unique tags"),
6217
6510
  periodic_notes: z3.array(PeriodicNoteInfoSchema).optional().describe("Detected periodic note conventions"),
6218
6511
  config: z3.record(z3.unknown()).optional().describe("Current flywheel config (paths, templates, etc.)"),
6512
+ last_rebuild: z3.object({
6513
+ trigger: z3.string(),
6514
+ timestamp: z3.number(),
6515
+ duration_ms: z3.number(),
6516
+ ago_seconds: z3.number()
6517
+ }).optional().describe("Most recent index rebuild event"),
6219
6518
  recommendations: z3.array(z3.string()).describe("Suggested actions if any issues detected")
6220
6519
  };
6221
6520
  server2.registerTool(
@@ -6235,7 +6534,7 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
6235
6534
  const indexErrorObj = getIndexError();
6236
6535
  let vaultAccessible = false;
6237
6536
  try {
6238
- fs8.accessSync(vaultPath2, fs8.constants.R_OK);
6537
+ fs9.accessSync(vaultPath2, fs9.constants.R_OK);
6239
6538
  vaultAccessible = true;
6240
6539
  } catch {
6241
6540
  vaultAccessible = false;
@@ -6284,6 +6583,23 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
6284
6583
  }
6285
6584
  const config = getConfig();
6286
6585
  const configInfo = Object.keys(config).length > 0 ? config : void 0;
6586
+ let lastRebuild;
6587
+ const stateDb2 = getStateDb();
6588
+ if (stateDb2) {
6589
+ try {
6590
+ const events = getRecentIndexEvents(stateDb2, 1);
6591
+ if (events.length > 0) {
6592
+ const event = events[0];
6593
+ lastRebuild = {
6594
+ trigger: event.trigger,
6595
+ timestamp: event.timestamp,
6596
+ duration_ms: event.duration_ms,
6597
+ ago_seconds: Math.floor((Date.now() - event.timestamp) / 1e3)
6598
+ };
6599
+ }
6600
+ } catch {
6601
+ }
6602
+ }
6287
6603
  const output = {
6288
6604
  status,
6289
6605
  vault_accessible: vaultAccessible,
@@ -6299,6 +6615,7 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
6299
6615
  tag_count: tagCount,
6300
6616
  periodic_notes: periodicNotes && periodicNotes.length > 0 ? periodicNotes : void 0,
6301
6617
  config: configInfo,
6618
+ last_rebuild: lastRebuild,
6302
6619
  recommendations
6303
6620
  };
6304
6621
  return {
@@ -6348,8 +6665,8 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
6348
6665
  daily_counts: z3.record(z3.number())
6349
6666
  }).describe("Activity summary for the last 7 days")
6350
6667
  };
6351
- function isPeriodicNote(path25) {
6352
- const filename = path25.split("/").pop() || "";
6668
+ function isPeriodicNote(path28) {
6669
+ const filename = path28.split("/").pop() || "";
6353
6670
  const nameWithoutExt = filename.replace(/\.md$/, "");
6354
6671
  const patterns = [
6355
6672
  /^\d{4}-\d{2}-\d{2}$/,
@@ -6364,7 +6681,7 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
6364
6681
  // YYYY (yearly)
6365
6682
  ];
6366
6683
  const periodicFolders = ["daily", "weekly", "monthly", "quarterly", "yearly", "journal", "journals"];
6367
- const folder = path25.split("/")[0]?.toLowerCase() || "";
6684
+ const folder = path28.split("/")[0]?.toLowerCase() || "";
6368
6685
  return patterns.some((p) => p.test(nameWithoutExt)) || periodicFolders.includes(folder);
6369
6686
  }
6370
6687
  server2.registerTool(
@@ -6533,10 +6850,10 @@ function sortNotes(notes, sortBy, order) {
6533
6850
  function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
6534
6851
  server2.tool(
6535
6852
  "search",
6536
- 'Search the vault across metadata, content, and entities. Scope controls what to search: "metadata" for frontmatter/tags/folders, "content" for full-text search (FTS5), "entities" for people/projects/technologies, "all" (default) tries metadata then falls back to content search.\n\nExample: search({ query: "quarterly review", scope: "content", limit: 5 })\nExample: search({ where: { type: "project", status: "active" }, scope: "metadata" })',
6853
+ '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" })',
6537
6854
  {
6538
6855
  query: z4.string().optional().describe('Search query text. Required for scope "content", "entities", "all". For "metadata" scope, use filters instead.'),
6539
- 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)"),
6856
+ 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)."),
6540
6857
  // Metadata filters (used with scope "metadata" or "all")
6541
6858
  where: z4.record(z4.unknown()).optional().describe('Frontmatter filters as key-value pairs. Example: { "type": "project", "status": "active" }'),
6542
6859
  has_tag: z4.string().optional().describe("Filter to notes with this tag"),
@@ -6645,12 +6962,42 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
6645
6962
  console.error("[FTS5] Index stale or missing, rebuilding...");
6646
6963
  await buildFTS5Index(vaultPath2);
6647
6964
  }
6648
- const results = searchFTS5(vaultPath2, query, limit);
6965
+ const fts5Results = searchFTS5(vaultPath2, query, limit);
6966
+ if (hasEmbeddingsIndex()) {
6967
+ try {
6968
+ const semanticResults = await semanticSearch(query, limit);
6969
+ const fts5Ranked = fts5Results.map((r) => ({ path: r.path, title: r.title, snippet: r.snippet }));
6970
+ const semanticRanked = semanticResults.map((r) => ({ path: r.path, title: r.title }));
6971
+ const rrfScores = reciprocalRankFusion(fts5Ranked, semanticRanked);
6972
+ const allPaths = /* @__PURE__ */ new Set([...fts5Results.map((r) => r.path), ...semanticResults.map((r) => r.path)]);
6973
+ const fts5Map = new Map(fts5Results.map((r) => [r.path, r]));
6974
+ const semanticMap = new Map(semanticResults.map((r) => [r.path, r]));
6975
+ const merged = Array.from(allPaths).map((p) => ({
6976
+ path: p,
6977
+ title: fts5Map.get(p)?.title || semanticMap.get(p)?.title || p.replace(/\.md$/, "").split("/").pop() || p,
6978
+ snippet: fts5Map.get(p)?.snippet,
6979
+ rrf_score: Math.round((rrfScores.get(p) || 0) * 1e4) / 1e4,
6980
+ in_fts5: fts5Map.has(p),
6981
+ in_semantic: semanticMap.has(p)
6982
+ }));
6983
+ merged.sort((a, b) => b.rrf_score - a.rrf_score);
6984
+ return { content: [{ type: "text", text: JSON.stringify({
6985
+ scope,
6986
+ method: "hybrid",
6987
+ query,
6988
+ total_results: Math.min(merged.length, limit),
6989
+ results: merged.slice(0, limit)
6990
+ }, null, 2) }] };
6991
+ } catch (err) {
6992
+ console.error("[Semantic] Hybrid search failed, falling back to FTS5:", err instanceof Error ? err.message : err);
6993
+ }
6994
+ }
6649
6995
  return { content: [{ type: "text", text: JSON.stringify({
6650
6996
  scope: "content",
6997
+ method: "fts5",
6651
6998
  query,
6652
- total_results: results.length,
6653
- results
6999
+ total_results: fts5Results.length,
7000
+ results: fts5Results
6654
7001
  }, null, 2) }] };
6655
7002
  }
6656
7003
  return { content: [{ type: "text", text: JSON.stringify({ error: "Invalid scope" }, null, 2) }] };
@@ -6659,7 +7006,7 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
6659
7006
  }
6660
7007
 
6661
7008
  // src/tools/read/system.ts
6662
- import * as fs9 from "fs";
7009
+ import * as fs10 from "fs";
6663
7010
  import * as path9 from "path";
6664
7011
  import { z as z5 } from "zod";
6665
7012
  import { scanVaultEntities as scanVaultEntities2 } from "@velvetmonkey/vault-core";
@@ -6736,12 +7083,20 @@ function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfi
6736
7083
  } catch (err) {
6737
7084
  console.error("[Flywheel] FTS5 rebuild failed:", err);
6738
7085
  }
7086
+ const duration = Date.now() - startTime;
7087
+ if (stateDb2) {
7088
+ recordIndexEvent(stateDb2, {
7089
+ trigger: "manual_refresh",
7090
+ duration_ms: duration,
7091
+ note_count: newIndex.notes.size
7092
+ });
7093
+ }
6739
7094
  const output = {
6740
7095
  success: true,
6741
7096
  notes_count: newIndex.notes.size,
6742
7097
  entities_count: newIndex.entities.size,
6743
7098
  fts5_notes: fts5Notes,
6744
- duration_ms: Date.now() - startTime
7099
+ duration_ms: duration
6745
7100
  };
6746
7101
  return {
6747
7102
  content: [
@@ -6755,12 +7110,22 @@ function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfi
6755
7110
  } catch (err) {
6756
7111
  setIndexState("error");
6757
7112
  setIndexError(err instanceof Error ? err : new Error(String(err)));
7113
+ const duration = Date.now() - startTime;
7114
+ const stateDb2 = getStateDb?.();
7115
+ if (stateDb2) {
7116
+ recordIndexEvent(stateDb2, {
7117
+ trigger: "manual_refresh",
7118
+ duration_ms: duration,
7119
+ success: false,
7120
+ error: err instanceof Error ? err.message : String(err)
7121
+ });
7122
+ }
6758
7123
  const output = {
6759
7124
  success: false,
6760
7125
  notes_count: 0,
6761
7126
  entities_count: 0,
6762
7127
  fts5_notes: 0,
6763
- duration_ms: Date.now() - startTime
7128
+ duration_ms: duration
6764
7129
  };
6765
7130
  return {
6766
7131
  content: [
@@ -6876,7 +7241,7 @@ function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfi
6876
7241
  }
6877
7242
  try {
6878
7243
  const fullPath = path9.join(vaultPath2, note.path);
6879
- const content = await fs9.promises.readFile(fullPath, "utf-8");
7244
+ const content = await fs10.promises.readFile(fullPath, "utf-8");
6880
7245
  const lines = content.split("\n");
6881
7246
  for (let i = 0; i < lines.length; i++) {
6882
7247
  const line = lines[i];
@@ -6992,7 +7357,7 @@ function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfi
6992
7357
  if (include_word_count) {
6993
7358
  try {
6994
7359
  const fullPath = path9.join(vaultPath2, resolvedPath);
6995
- const content = await fs9.promises.readFile(fullPath, "utf-8");
7360
+ const content = await fs10.promises.readFile(fullPath, "utf-8");
6996
7361
  wordCount = content.split(/\s+/).filter((w) => w.length > 0).length;
6997
7362
  } catch {
6998
7363
  }
@@ -7094,7 +7459,7 @@ function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfi
7094
7459
  import { z as z6 } from "zod";
7095
7460
 
7096
7461
  // src/tools/read/structure.ts
7097
- import * as fs10 from "fs";
7462
+ import * as fs11 from "fs";
7098
7463
  import * as path10 from "path";
7099
7464
  var HEADING_REGEX = /^(#{1,6})\s+(.+)$/;
7100
7465
  function extractHeadings(content) {
@@ -7152,7 +7517,7 @@ async function getNoteStructure(index, notePath, vaultPath2) {
7152
7517
  const absolutePath = path10.join(vaultPath2, notePath);
7153
7518
  let content;
7154
7519
  try {
7155
- content = await fs10.promises.readFile(absolutePath, "utf-8");
7520
+ content = await fs11.promises.readFile(absolutePath, "utf-8");
7156
7521
  } catch {
7157
7522
  return null;
7158
7523
  }
@@ -7175,7 +7540,7 @@ async function getSectionContent(index, notePath, headingText, vaultPath2, inclu
7175
7540
  const absolutePath = path10.join(vaultPath2, notePath);
7176
7541
  let content;
7177
7542
  try {
7178
- content = await fs10.promises.readFile(absolutePath, "utf-8");
7543
+ content = await fs11.promises.readFile(absolutePath, "utf-8");
7179
7544
  } catch {
7180
7545
  return null;
7181
7546
  }
@@ -7217,7 +7582,7 @@ async function findSections(index, headingPattern, vaultPath2, folder) {
7217
7582
  const absolutePath = path10.join(vaultPath2, note.path);
7218
7583
  let content;
7219
7584
  try {
7220
- content = await fs10.promises.readFile(absolutePath, "utf-8");
7585
+ content = await fs11.promises.readFile(absolutePath, "utf-8");
7221
7586
  } catch {
7222
7587
  continue;
7223
7588
  }
@@ -7237,7 +7602,7 @@ async function findSections(index, headingPattern, vaultPath2, folder) {
7237
7602
  }
7238
7603
 
7239
7604
  // src/tools/read/tasks.ts
7240
- import * as fs11 from "fs";
7605
+ import * as fs12 from "fs";
7241
7606
  import * as path11 from "path";
7242
7607
  var TASK_REGEX = /^(\s*)- \[([ xX\-])\]\s+(.+)$/;
7243
7608
  var TAG_REGEX2 = /#([a-zA-Z][a-zA-Z0-9_/-]*)/g;
@@ -7264,7 +7629,7 @@ function extractDueDate(text) {
7264
7629
  async function extractTasksFromNote(notePath, absolutePath) {
7265
7630
  let content;
7266
7631
  try {
7267
- content = await fs11.promises.readFile(absolutePath, "utf-8");
7632
+ content = await fs12.promises.readFile(absolutePath, "utf-8");
7268
7633
  } catch {
7269
7634
  return [];
7270
7635
  }
@@ -7369,18 +7734,18 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
7369
7734
  include_content: z6.boolean().default(false).describe("Include the text content under each top-level section")
7370
7735
  }
7371
7736
  },
7372
- async ({ path: path25, include_content }) => {
7737
+ async ({ path: path28, include_content }) => {
7373
7738
  const index = getIndex();
7374
7739
  const vaultPath2 = getVaultPath();
7375
- const result = await getNoteStructure(index, path25, vaultPath2);
7740
+ const result = await getNoteStructure(index, path28, vaultPath2);
7376
7741
  if (!result) {
7377
7742
  return {
7378
- content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path25 }, null, 2) }]
7743
+ content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path28 }, null, 2) }]
7379
7744
  };
7380
7745
  }
7381
7746
  if (include_content) {
7382
7747
  for (const section of result.sections) {
7383
- const sectionResult = await getSectionContent(index, path25, section.heading.text, vaultPath2, true);
7748
+ const sectionResult = await getSectionContent(index, path28, section.heading.text, vaultPath2, true);
7384
7749
  if (sectionResult) {
7385
7750
  section.content = sectionResult.content;
7386
7751
  }
@@ -7402,15 +7767,15 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
7402
7767
  include_subheadings: z6.boolean().default(true).describe("Include content under subheadings")
7403
7768
  }
7404
7769
  },
7405
- async ({ path: path25, heading, include_subheadings }) => {
7770
+ async ({ path: path28, heading, include_subheadings }) => {
7406
7771
  const index = getIndex();
7407
7772
  const vaultPath2 = getVaultPath();
7408
- const result = await getSectionContent(index, path25, heading, vaultPath2, include_subheadings);
7773
+ const result = await getSectionContent(index, path28, heading, vaultPath2, include_subheadings);
7409
7774
  if (!result) {
7410
7775
  return {
7411
7776
  content: [{ type: "text", text: JSON.stringify({
7412
7777
  error: "Section not found",
7413
- path: path25,
7778
+ path: path28,
7414
7779
  heading
7415
7780
  }, null, 2) }]
7416
7781
  };
@@ -7464,16 +7829,16 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
7464
7829
  offset: z6.coerce.number().default(0).describe("Number of results to skip (for pagination)")
7465
7830
  }
7466
7831
  },
7467
- async ({ path: path25, status, has_due_date, folder, tag, limit: requestedLimit, offset }) => {
7832
+ async ({ path: path28, status, has_due_date, folder, tag, limit: requestedLimit, offset }) => {
7468
7833
  const limit = Math.min(requestedLimit ?? 25, MAX_LIMIT);
7469
7834
  const index = getIndex();
7470
7835
  const vaultPath2 = getVaultPath();
7471
7836
  const config = getConfig();
7472
- if (path25) {
7473
- const result2 = await getTasksFromNote(index, path25, vaultPath2, config.exclude_task_tags || []);
7837
+ if (path28) {
7838
+ const result2 = await getTasksFromNote(index, path28, vaultPath2, config.exclude_task_tags || []);
7474
7839
  if (!result2) {
7475
7840
  return {
7476
- content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path25 }, null, 2) }]
7841
+ content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path28 }, null, 2) }]
7477
7842
  };
7478
7843
  }
7479
7844
  let filtered = result2;
@@ -7483,7 +7848,7 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
7483
7848
  const paged2 = filtered.slice(offset, offset + limit);
7484
7849
  return {
7485
7850
  content: [{ type: "text", text: JSON.stringify({
7486
- path: path25,
7851
+ path: path28,
7487
7852
  total_count: filtered.length,
7488
7853
  returned_count: paged2.length,
7489
7854
  open: result2.filter((t) => t.status === "open").length,
@@ -7598,7 +7963,7 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
7598
7963
 
7599
7964
  // src/tools/read/migrations.ts
7600
7965
  import { z as z7 } from "zod";
7601
- import * as fs12 from "fs/promises";
7966
+ import * as fs13 from "fs/promises";
7602
7967
  import * as path12 from "path";
7603
7968
  import matter2 from "gray-matter";
7604
7969
  function getNotesInFolder(index, folder) {
@@ -7614,7 +7979,7 @@ function getNotesInFolder(index, folder) {
7614
7979
  async function readFileContent(notePath, vaultPath2) {
7615
7980
  const fullPath = path12.join(vaultPath2, notePath);
7616
7981
  try {
7617
- return await fs12.readFile(fullPath, "utf-8");
7982
+ return await fs13.readFile(fullPath, "utf-8");
7618
7983
  } catch {
7619
7984
  return null;
7620
7985
  }
@@ -7622,7 +7987,7 @@ async function readFileContent(notePath, vaultPath2) {
7622
7987
  async function writeFileContent(notePath, vaultPath2, content) {
7623
7988
  const fullPath = path12.join(vaultPath2, notePath);
7624
7989
  try {
7625
- await fs12.writeFile(fullPath, content, "utf-8");
7990
+ await fs13.writeFile(fullPath, content, "utf-8");
7626
7991
  return true;
7627
7992
  } catch {
7628
7993
  return false;
@@ -7800,303 +8165,12 @@ function registerMigrationTools(server2, getIndex, getVaultPath) {
7800
8165
  }
7801
8166
 
7802
8167
  // src/tools/read/graphAnalysis.ts
8168
+ import fs14 from "node:fs";
8169
+ import path13 from "node:path";
7803
8170
  import { z as z8 } from "zod";
7804
- function registerGraphAnalysisTools(server2, getIndex, getVaultPath) {
7805
- server2.registerTool(
7806
- "graph_analysis",
7807
- {
7808
- title: "Graph Analysis",
7809
- description: 'Analyze vault link graph structure. Use analysis to pick the mode:\n- "orphans": Notes with no backlinks (disconnected content)\n- "dead_ends": Notes with backlinks but no outgoing links\n- "sources": Notes with outgoing links but no backlinks\n- "hubs": Highly connected notes (many links to/from)\n- "stale": Important notes (by backlink count) not recently modified\n\nExample: graph_analysis({ analysis: "hubs", limit: 10 })\nExample: graph_analysis({ analysis: "stale", days: 30, min_backlinks: 3 })',
7810
- inputSchema: {
7811
- analysis: z8.enum(["orphans", "dead_ends", "sources", "hubs", "stale"]).describe("Type of graph analysis to perform"),
7812
- folder: z8.string().optional().describe("Limit to notes in this folder (orphans, dead_ends, sources)"),
7813
- min_links: z8.coerce.number().default(5).describe("Minimum total connections for hubs"),
7814
- min_backlinks: z8.coerce.number().default(1).describe("Minimum backlinks (dead_ends, stale)"),
7815
- min_outlinks: z8.coerce.number().default(1).describe("Minimum outlinks (sources)"),
7816
- days: z8.coerce.number().optional().describe("Notes not modified in this many days (stale, required)"),
7817
- limit: z8.coerce.number().default(50).describe("Maximum number of results to return"),
7818
- offset: z8.coerce.number().default(0).describe("Number of results to skip (for pagination)")
7819
- }
7820
- },
7821
- async ({ analysis, folder, min_links, min_backlinks, min_outlinks, days, limit: requestedLimit, offset }) => {
7822
- requireIndex();
7823
- const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
7824
- const index = getIndex();
7825
- switch (analysis) {
7826
- case "orphans": {
7827
- const allOrphans = findOrphanNotes(index, folder);
7828
- const orphans = allOrphans.slice(offset, offset + limit);
7829
- return {
7830
- content: [{ type: "text", text: JSON.stringify({
7831
- analysis: "orphans",
7832
- orphan_count: allOrphans.length,
7833
- returned_count: orphans.length,
7834
- folder,
7835
- orphans: orphans.map((o) => ({
7836
- path: o.path,
7837
- title: o.title,
7838
- modified: o.modified.toISOString()
7839
- }))
7840
- }, null, 2) }]
7841
- };
7842
- }
7843
- case "dead_ends": {
7844
- const allResults = findDeadEnds(index, folder, min_backlinks);
7845
- const result = allResults.slice(offset, offset + limit);
7846
- return {
7847
- content: [{ type: "text", text: JSON.stringify({
7848
- analysis: "dead_ends",
7849
- criteria: { folder, min_backlinks },
7850
- total_count: allResults.length,
7851
- returned_count: result.length,
7852
- dead_ends: result
7853
- }, null, 2) }]
7854
- };
7855
- }
7856
- case "sources": {
7857
- const allResults = findSources(index, folder, min_outlinks);
7858
- const result = allResults.slice(offset, offset + limit);
7859
- return {
7860
- content: [{ type: "text", text: JSON.stringify({
7861
- analysis: "sources",
7862
- criteria: { folder, min_outlinks },
7863
- total_count: allResults.length,
7864
- returned_count: result.length,
7865
- sources: result
7866
- }, null, 2) }]
7867
- };
7868
- }
7869
- case "hubs": {
7870
- const allHubs = findHubNotes(index, min_links);
7871
- const hubs = allHubs.slice(offset, offset + limit);
7872
- return {
7873
- content: [{ type: "text", text: JSON.stringify({
7874
- analysis: "hubs",
7875
- hub_count: allHubs.length,
7876
- returned_count: hubs.length,
7877
- min_links,
7878
- hubs: hubs.map((h) => ({
7879
- path: h.path,
7880
- title: h.title,
7881
- backlink_count: h.backlink_count,
7882
- forward_link_count: h.forward_link_count,
7883
- total_connections: h.total_connections
7884
- }))
7885
- }, null, 2) }]
7886
- };
7887
- }
7888
- case "stale": {
7889
- if (days === void 0) {
7890
- return {
7891
- content: [{ type: "text", text: JSON.stringify({
7892
- error: "days parameter is required for stale analysis"
7893
- }, null, 2) }]
7894
- };
7895
- }
7896
- const result = getStaleNotes(index, days, min_backlinks).slice(0, limit);
7897
- return {
7898
- content: [{ type: "text", text: JSON.stringify({
7899
- analysis: "stale",
7900
- criteria: { days, min_backlinks },
7901
- count: result.length,
7902
- notes: result.map((n) => ({
7903
- ...n,
7904
- modified: n.modified.toISOString()
7905
- }))
7906
- }, null, 2) }]
7907
- };
7908
- }
7909
- }
7910
- }
7911
- );
7912
- }
7913
-
7914
- // src/tools/read/vaultSchema.ts
7915
- import { z as z9 } from "zod";
7916
8171
 
7917
- // src/tools/read/frontmatter.ts
8172
+ // src/tools/read/schema.ts
7918
8173
  function getValueType(value) {
7919
- if (value === null) return "null";
7920
- if (value === void 0) return "undefined";
7921
- if (Array.isArray(value)) return "array";
7922
- if (value instanceof Date) return "date";
7923
- return typeof value;
7924
- }
7925
- function getFrontmatterSchema(index) {
7926
- const fieldMap = /* @__PURE__ */ new Map();
7927
- let notesWithFrontmatter = 0;
7928
- for (const note of index.notes.values()) {
7929
- const fm = note.frontmatter;
7930
- if (!fm || Object.keys(fm).length === 0) continue;
7931
- notesWithFrontmatter++;
7932
- for (const [key, value] of Object.entries(fm)) {
7933
- if (!fieldMap.has(key)) {
7934
- fieldMap.set(key, {
7935
- types: /* @__PURE__ */ new Set(),
7936
- count: 0,
7937
- examples: [],
7938
- notes: []
7939
- });
7940
- }
7941
- const info = fieldMap.get(key);
7942
- info.count++;
7943
- info.types.add(getValueType(value));
7944
- if (info.examples.length < 5) {
7945
- const valueStr = JSON.stringify(value);
7946
- const existingStrs = info.examples.map((e) => JSON.stringify(e));
7947
- if (!existingStrs.includes(valueStr)) {
7948
- info.examples.push(value);
7949
- }
7950
- }
7951
- if (info.notes.length < 5) {
7952
- info.notes.push(note.path);
7953
- }
7954
- }
7955
- }
7956
- const fields = Array.from(fieldMap.entries()).map(([name, info]) => ({
7957
- name,
7958
- types: Array.from(info.types),
7959
- count: info.count,
7960
- examples: info.examples,
7961
- notes_sample: info.notes
7962
- })).sort((a, b) => b.count - a.count);
7963
- return {
7964
- total_notes: index.notes.size,
7965
- notes_with_frontmatter: notesWithFrontmatter,
7966
- field_count: fields.length,
7967
- fields
7968
- };
7969
- }
7970
- function getFieldValues(index, fieldName) {
7971
- const valueMap = /* @__PURE__ */ new Map();
7972
- let totalWithField = 0;
7973
- for (const note of index.notes.values()) {
7974
- const value = note.frontmatter[fieldName];
7975
- if (value === void 0) continue;
7976
- totalWithField++;
7977
- const values = Array.isArray(value) ? value : [value];
7978
- for (const v of values) {
7979
- const key = JSON.stringify(v);
7980
- if (!valueMap.has(key)) {
7981
- valueMap.set(key, {
7982
- value: v,
7983
- count: 0,
7984
- notes: []
7985
- });
7986
- }
7987
- const info = valueMap.get(key);
7988
- info.count++;
7989
- info.notes.push(note.path);
7990
- }
7991
- }
7992
- const valuesList = Array.from(valueMap.values()).sort((a, b) => b.count - a.count);
7993
- return {
7994
- field: fieldName,
7995
- total_notes_with_field: totalWithField,
7996
- unique_values: valuesList.length,
7997
- values: valuesList
7998
- };
7999
- }
8000
- function findFrontmatterInconsistencies(index) {
8001
- const schema = getFrontmatterSchema(index);
8002
- const inconsistencies = [];
8003
- for (const field of schema.fields) {
8004
- if (field.types.length > 1) {
8005
- const examples = [];
8006
- for (const note of index.notes.values()) {
8007
- const value = note.frontmatter[field.name];
8008
- if (value === void 0) continue;
8009
- const type = getValueType(value);
8010
- if (!examples.some((e) => e.type === type)) {
8011
- examples.push({
8012
- type,
8013
- value,
8014
- note: note.path
8015
- });
8016
- }
8017
- if (examples.length >= field.types.length) break;
8018
- }
8019
- inconsistencies.push({
8020
- field: field.name,
8021
- types_found: field.types,
8022
- examples
8023
- });
8024
- }
8025
- }
8026
- return inconsistencies;
8027
- }
8028
- function validateFrontmatter(index, schema, folder) {
8029
- const results = [];
8030
- for (const note of index.notes.values()) {
8031
- if (folder && !note.path.startsWith(folder)) continue;
8032
- const issues = [];
8033
- for (const [fieldName, fieldSchema] of Object.entries(schema)) {
8034
- const value = note.frontmatter[fieldName];
8035
- if (fieldSchema.required && value === void 0) {
8036
- issues.push({
8037
- field: fieldName,
8038
- issue: "missing",
8039
- expected: "value required"
8040
- });
8041
- continue;
8042
- }
8043
- if (value === void 0) continue;
8044
- if (fieldSchema.type) {
8045
- const actualType = getValueType(value);
8046
- const allowedTypes = Array.isArray(fieldSchema.type) ? fieldSchema.type : [fieldSchema.type];
8047
- if (!allowedTypes.includes(actualType)) {
8048
- issues.push({
8049
- field: fieldName,
8050
- issue: "wrong_type",
8051
- expected: allowedTypes.join(" | "),
8052
- actual: actualType
8053
- });
8054
- }
8055
- }
8056
- if (fieldSchema.values) {
8057
- const valueStr = JSON.stringify(value);
8058
- const allowedStrs = fieldSchema.values.map((v) => JSON.stringify(v));
8059
- if (!allowedStrs.includes(valueStr)) {
8060
- issues.push({
8061
- field: fieldName,
8062
- issue: "invalid_value",
8063
- expected: fieldSchema.values.map((v) => String(v)).join(" | "),
8064
- actual: String(value)
8065
- });
8066
- }
8067
- }
8068
- }
8069
- if (issues.length > 0) {
8070
- results.push({
8071
- path: note.path,
8072
- issues
8073
- });
8074
- }
8075
- }
8076
- return results;
8077
- }
8078
- function findMissingFrontmatter(index, folderSchemas) {
8079
- const results = [];
8080
- for (const note of index.notes.values()) {
8081
- for (const [folder, requiredFields] of Object.entries(folderSchemas)) {
8082
- if (!note.path.startsWith(folder)) continue;
8083
- const missing = requiredFields.filter(
8084
- (field) => note.frontmatter[field] === void 0
8085
- );
8086
- if (missing.length > 0) {
8087
- results.push({
8088
- path: note.path,
8089
- folder,
8090
- missing_fields: missing
8091
- });
8092
- }
8093
- }
8094
- }
8095
- return results;
8096
- }
8097
-
8098
- // src/tools/read/schema.ts
8099
- function getValueType2(value) {
8100
8174
  if (value === null) return "null";
8101
8175
  if (value === void 0) return "undefined";
8102
8176
  if (Array.isArray(value)) {
@@ -8181,7 +8255,7 @@ function inferFolderConventions(index, folder, minConfidence = 0.5) {
8181
8255
  }
8182
8256
  const stats = fieldStats.get(key);
8183
8257
  stats.count++;
8184
- const type = getValueType2(value);
8258
+ const type = getValueType(value);
8185
8259
  stats.types.set(type, (stats.types.get(type) || 0) + 1);
8186
8260
  const valueStr = JSON.stringify(value);
8187
8261
  stats.values.set(valueStr, (stats.values.get(valueStr) || 0) + 1);
@@ -8313,7 +8387,7 @@ function suggestFieldValues(index, field, context) {
8313
8387
  const value = note.frontmatter[field];
8314
8388
  if (value === void 0) continue;
8315
8389
  totalWithField++;
8316
- const type = getValueType2(value);
8390
+ const type = getValueType(value);
8317
8391
  typeCounts.set(type, (typeCounts.get(type) || 0) + 1);
8318
8392
  const values = Array.isArray(value) ? value : [value];
8319
8393
  for (const v of values) {
@@ -8372,148 +8446,480 @@ function suggestFieldValues(index, field, context) {
8372
8446
  is_enumerable: isEnumerable
8373
8447
  };
8374
8448
  }
8449
+ var SKIP_CONTRADICTION_FIELDS = /* @__PURE__ */ new Set([
8450
+ "title",
8451
+ "created",
8452
+ "modified",
8453
+ "path",
8454
+ "aliases",
8455
+ "tags",
8456
+ "date",
8457
+ "updated",
8458
+ "word_count",
8459
+ "link_count"
8460
+ ]);
8461
+ function findContradictions(index, entity) {
8462
+ const contradictions = [];
8463
+ const entitiesToCheck = [];
8464
+ if (entity) {
8465
+ const normalized = entity.toLowerCase();
8466
+ const entityPath = index.entities.get(normalized);
8467
+ if (entityPath) {
8468
+ entitiesToCheck.push([normalized, entityPath]);
8469
+ }
8470
+ } else {
8471
+ for (const [name, entityPath] of index.entities) {
8472
+ entitiesToCheck.push([name, entityPath]);
8473
+ }
8474
+ }
8475
+ for (const [entityName, _entityPath] of entitiesToCheck) {
8476
+ const backlinks = index.backlinks.get(entityName);
8477
+ if (!backlinks || backlinks.length < 2) continue;
8478
+ const sourcePaths = [...new Set(backlinks.map((bl) => bl.source))];
8479
+ if (sourcePaths.length < 2) continue;
8480
+ const notesFrontmatter = [];
8481
+ for (const srcPath of sourcePaths) {
8482
+ const note = index.notes.get(srcPath);
8483
+ if (note && Object.keys(note.frontmatter).length > 0) {
8484
+ notesFrontmatter.push({ path: srcPath, fm: note.frontmatter });
8485
+ }
8486
+ }
8487
+ if (notesFrontmatter.length < 2) continue;
8488
+ const allFields = /* @__PURE__ */ new Set();
8489
+ for (const { fm } of notesFrontmatter) {
8490
+ for (const key of Object.keys(fm)) {
8491
+ if (!SKIP_CONTRADICTION_FIELDS.has(key)) {
8492
+ allFields.add(key);
8493
+ }
8494
+ }
8495
+ }
8496
+ for (const field of allFields) {
8497
+ const valueMap = /* @__PURE__ */ new Map();
8498
+ for (const { path: notePath, fm } of notesFrontmatter) {
8499
+ if (fm[field] === void 0) continue;
8500
+ const key = JSON.stringify(fm[field]);
8501
+ if (!valueMap.has(key)) {
8502
+ valueMap.set(key, []);
8503
+ }
8504
+ valueMap.get(key).push(notePath);
8505
+ }
8506
+ if (valueMap.size >= 2) {
8507
+ contradictions.push({
8508
+ entity: entityName,
8509
+ field,
8510
+ values: Array.from(valueMap.entries()).map(([serialized, notes]) => ({
8511
+ value: JSON.parse(serialized),
8512
+ notes
8513
+ }))
8514
+ });
8515
+ }
8516
+ }
8517
+ }
8518
+ return contradictions;
8519
+ }
8375
8520
 
8376
- // src/tools/read/vaultSchema.ts
8377
- function registerVaultSchemaTools(server2, getIndex, getVaultPath) {
8378
- server2.registerTool(
8379
- "vault_schema",
8380
- {
8381
- title: "Vault Schema",
8382
- description: 'Analyze and validate vault frontmatter schema. Use analysis to pick the mode:\n- "overview": Schema of all frontmatter fields across the vault\n- "field_values": All unique values for a specific field\n- "inconsistencies": Fields with multiple types across notes\n- "validate": Validate notes against a provided schema\n- "missing": Find notes missing expected fields by folder\n- "conventions": Auto-detect metadata conventions for a folder\n- "incomplete": Find notes missing expected fields (inferred)\n- "suggest_values": Suggest values for a field based on usage\n\nExample: vault_schema({ analysis: "field_values", field: "status" })\nExample: vault_schema({ analysis: "conventions", folder: "projects" })',
8383
- inputSchema: {
8384
- analysis: z9.enum([
8385
- "overview",
8386
- "field_values",
8387
- "inconsistencies",
8388
- "validate",
8389
- "missing",
8390
- "conventions",
8391
- "incomplete",
8392
- "suggest_values"
8393
- ]).describe("Type of schema analysis to perform"),
8394
- field: z9.string().optional().describe("Field name (field_values, suggest_values)"),
8395
- folder: z9.string().optional().describe("Folder to scope analysis to"),
8396
- schema: z9.record(z9.object({
8397
- required: z9.boolean().optional().describe("Whether field is required"),
8398
- type: z9.union([z9.string(), z9.array(z9.string())]).optional().describe("Expected type(s)"),
8399
- values: z9.array(z9.unknown()).optional().describe("Allowed values")
8400
- })).optional().describe("Schema to validate against (validate mode)"),
8401
- folder_schemas: z9.record(z9.array(z9.string())).optional().describe("Map of folder paths to required fields (missing mode)"),
8402
- min_confidence: z9.coerce.number().min(0).max(1).optional().describe("Minimum confidence threshold (conventions)"),
8403
- min_frequency: z9.coerce.number().min(0).max(1).optional().describe("Minimum field frequency (incomplete)"),
8404
- existing_frontmatter: z9.record(z9.unknown()).optional().describe("Existing frontmatter for context (suggest_values)"),
8405
- limit: z9.coerce.number().default(50).describe("Maximum results to return"),
8406
- offset: z9.coerce.number().default(0).describe("Number of results to skip")
8521
+ // src/core/shared/graphSnapshots.ts
8522
+ function computeGraphMetrics(index) {
8523
+ const noteCount = index.notes.size;
8524
+ if (noteCount === 0) {
8525
+ return {
8526
+ avg_degree: 0,
8527
+ max_degree: 0,
8528
+ cluster_count: 0,
8529
+ largest_cluster_size: 0,
8530
+ hub_scores_top10: []
8531
+ };
8532
+ }
8533
+ const degreeMap = /* @__PURE__ */ new Map();
8534
+ const adjacency = /* @__PURE__ */ new Map();
8535
+ for (const [notePath, note] of index.notes) {
8536
+ if (!adjacency.has(notePath)) adjacency.set(notePath, /* @__PURE__ */ new Set());
8537
+ let degree = note.outlinks.length;
8538
+ for (const link of note.outlinks) {
8539
+ const targetLower = link.target.toLowerCase();
8540
+ const resolvedPath = index.entities.get(targetLower);
8541
+ if (resolvedPath && index.notes.has(resolvedPath)) {
8542
+ adjacency.get(notePath).add(resolvedPath);
8543
+ if (!adjacency.has(resolvedPath)) adjacency.set(resolvedPath, /* @__PURE__ */ new Set());
8544
+ adjacency.get(resolvedPath).add(notePath);
8407
8545
  }
8408
- },
8409
- async (params) => {
8410
- requireIndex();
8411
- const limit = Math.min(params.limit ?? 50, MAX_LIMIT);
8412
- const index = getIndex();
8413
- switch (params.analysis) {
8414
- case "overview": {
8415
- const result = getFrontmatterSchema(index);
8416
- return {
8417
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
8418
- };
8419
- }
8420
- case "field_values": {
8421
- if (!params.field) {
8422
- return {
8423
- content: [{ type: "text", text: JSON.stringify({
8424
- error: "field parameter is required for field_values analysis"
8425
- }, null, 2) }]
8426
- };
8546
+ }
8547
+ degreeMap.set(notePath, degree);
8548
+ }
8549
+ for (const [target, backlinks] of index.backlinks) {
8550
+ const targetLower = target.toLowerCase();
8551
+ const resolvedPath = index.entities.get(targetLower);
8552
+ if (resolvedPath && degreeMap.has(resolvedPath)) {
8553
+ degreeMap.set(resolvedPath, degreeMap.get(resolvedPath) + backlinks.length);
8554
+ }
8555
+ }
8556
+ let totalDegree = 0;
8557
+ let maxDegree = 0;
8558
+ let maxDegreeNote = "";
8559
+ for (const [notePath, degree] of degreeMap) {
8560
+ totalDegree += degree;
8561
+ if (degree > maxDegree) {
8562
+ maxDegree = degree;
8563
+ maxDegreeNote = notePath;
8564
+ }
8565
+ }
8566
+ const avgDegree = noteCount > 0 ? Math.round(totalDegree / noteCount * 100) / 100 : 0;
8567
+ const visited = /* @__PURE__ */ new Set();
8568
+ const clusters = [];
8569
+ for (const notePath of index.notes.keys()) {
8570
+ if (visited.has(notePath)) continue;
8571
+ const queue = [notePath];
8572
+ visited.add(notePath);
8573
+ let clusterSize = 0;
8574
+ while (queue.length > 0) {
8575
+ const current = queue.shift();
8576
+ clusterSize++;
8577
+ const neighbors = adjacency.get(current);
8578
+ if (neighbors) {
8579
+ for (const neighbor of neighbors) {
8580
+ if (!visited.has(neighbor)) {
8581
+ visited.add(neighbor);
8582
+ queue.push(neighbor);
8427
8583
  }
8428
- const result = getFieldValues(index, params.field);
8429
- return {
8430
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
8431
- };
8432
8584
  }
8433
- case "inconsistencies": {
8434
- const result = findFrontmatterInconsistencies(index);
8435
- return {
8436
- content: [{ type: "text", text: JSON.stringify({
8437
- inconsistency_count: result.length,
8438
- inconsistencies: result
8585
+ }
8586
+ }
8587
+ clusters.push(clusterSize);
8588
+ }
8589
+ const clusterCount = clusters.length;
8590
+ const largestClusterSize = clusters.length > 0 ? Math.max(...clusters) : 0;
8591
+ const sorted = Array.from(degreeMap.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10);
8592
+ const hubScoresTop10 = sorted.map(([notePath, degree]) => {
8593
+ const note = index.notes.get(notePath);
8594
+ return {
8595
+ entity: note?.title ?? notePath,
8596
+ degree
8597
+ };
8598
+ });
8599
+ return {
8600
+ avg_degree: avgDegree,
8601
+ max_degree: maxDegree,
8602
+ cluster_count: clusterCount,
8603
+ largest_cluster_size: largestClusterSize,
8604
+ hub_scores_top10: hubScoresTop10
8605
+ };
8606
+ }
8607
+ function recordGraphSnapshot(stateDb2, metrics) {
8608
+ const timestamp = Date.now();
8609
+ const insert = stateDb2.db.prepare(
8610
+ "INSERT INTO graph_snapshots (timestamp, metric, value, details) VALUES (?, ?, ?, ?)"
8611
+ );
8612
+ const transaction = stateDb2.db.transaction(() => {
8613
+ insert.run(timestamp, "avg_degree", metrics.avg_degree, null);
8614
+ insert.run(timestamp, "max_degree", metrics.max_degree, null);
8615
+ insert.run(timestamp, "cluster_count", metrics.cluster_count, null);
8616
+ insert.run(timestamp, "largest_cluster_size", metrics.largest_cluster_size, null);
8617
+ insert.run(
8618
+ timestamp,
8619
+ "hub_scores_top10",
8620
+ metrics.hub_scores_top10.length,
8621
+ JSON.stringify(metrics.hub_scores_top10)
8622
+ );
8623
+ });
8624
+ transaction();
8625
+ }
8626
+ function getGraphEvolution(stateDb2, daysBack = 30) {
8627
+ const now = Date.now();
8628
+ const cutoff = now - daysBack * 24 * 60 * 60 * 1e3;
8629
+ const SCALAR_METRICS = ["avg_degree", "max_degree", "cluster_count", "largest_cluster_size"];
8630
+ const latestRows = stateDb2.db.prepare(`
8631
+ SELECT metric, value FROM graph_snapshots
8632
+ WHERE metric IN ('avg_degree', 'max_degree', 'cluster_count', 'largest_cluster_size')
8633
+ GROUP BY metric
8634
+ HAVING timestamp = MAX(timestamp)
8635
+ `).all();
8636
+ const currentValues = /* @__PURE__ */ new Map();
8637
+ for (const row of latestRows) {
8638
+ currentValues.set(row.metric, row.value);
8639
+ }
8640
+ const previousRows = stateDb2.db.prepare(`
8641
+ SELECT metric, value FROM graph_snapshots
8642
+ WHERE metric IN ('avg_degree', 'max_degree', 'cluster_count', 'largest_cluster_size')
8643
+ AND timestamp >= ? AND timestamp <= ?
8644
+ GROUP BY metric
8645
+ HAVING timestamp = MIN(timestamp)
8646
+ `).all(cutoff, cutoff + 24 * 60 * 60 * 1e3);
8647
+ const previousValues = /* @__PURE__ */ new Map();
8648
+ for (const row of previousRows) {
8649
+ previousValues.set(row.metric, row.value);
8650
+ }
8651
+ if (previousValues.size === 0) {
8652
+ const fallbackRows = stateDb2.db.prepare(`
8653
+ SELECT metric, value FROM graph_snapshots
8654
+ WHERE metric IN ('avg_degree', 'max_degree', 'cluster_count', 'largest_cluster_size')
8655
+ AND timestamp >= ?
8656
+ GROUP BY metric
8657
+ HAVING timestamp = MIN(timestamp)
8658
+ `).all(cutoff);
8659
+ for (const row of fallbackRows) {
8660
+ previousValues.set(row.metric, row.value);
8661
+ }
8662
+ }
8663
+ const evolutions = [];
8664
+ for (const metric of SCALAR_METRICS) {
8665
+ const current = currentValues.get(metric) ?? 0;
8666
+ const previous = previousValues.get(metric) ?? current;
8667
+ const delta = current - previous;
8668
+ const deltaPct = previous !== 0 ? Math.round(delta / previous * 1e4) / 100 : delta !== 0 ? 100 : 0;
8669
+ let direction = "stable";
8670
+ if (delta > 0) direction = "up";
8671
+ if (delta < 0) direction = "down";
8672
+ evolutions.push({
8673
+ metric,
8674
+ current,
8675
+ previous,
8676
+ delta,
8677
+ delta_percent: deltaPct,
8678
+ direction
8679
+ });
8680
+ }
8681
+ return evolutions;
8682
+ }
8683
+ function getEmergingHubs(stateDb2, daysBack = 30) {
8684
+ const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1e3;
8685
+ const latestRow = stateDb2.db.prepare(
8686
+ `SELECT details FROM graph_snapshots
8687
+ WHERE metric = 'hub_scores_top10'
8688
+ ORDER BY timestamp DESC LIMIT 1`
8689
+ ).get();
8690
+ const previousRow = stateDb2.db.prepare(
8691
+ `SELECT details FROM graph_snapshots
8692
+ WHERE metric = 'hub_scores_top10' AND timestamp >= ?
8693
+ ORDER BY timestamp ASC LIMIT 1`
8694
+ ).get(cutoff);
8695
+ if (!latestRow?.details) return [];
8696
+ const currentHubs = JSON.parse(latestRow.details);
8697
+ const previousHubs = previousRow?.details ? JSON.parse(previousRow.details) : [];
8698
+ const previousMap = /* @__PURE__ */ new Map();
8699
+ for (const hub of previousHubs) {
8700
+ previousMap.set(hub.entity, hub.degree);
8701
+ }
8702
+ const emerging = currentHubs.map((hub) => {
8703
+ const prevDegree = previousMap.get(hub.entity) ?? 0;
8704
+ return {
8705
+ entity: hub.entity,
8706
+ current_degree: hub.degree,
8707
+ previous_degree: prevDegree,
8708
+ growth: hub.degree - prevDegree
8709
+ };
8710
+ });
8711
+ emerging.sort((a, b) => b.growth - a.growth);
8712
+ return emerging;
8713
+ }
8714
+ function purgeOldSnapshots(stateDb2, retentionDays = 90) {
8715
+ const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1e3;
8716
+ const result = stateDb2.db.prepare(
8717
+ "DELETE FROM graph_snapshots WHERE timestamp < ?"
8718
+ ).run(cutoff);
8719
+ return result.changes;
8720
+ }
8721
+
8722
+ // src/tools/read/graphAnalysis.ts
8723
+ function registerGraphAnalysisTools(server2, getIndex, getVaultPath, getStateDb) {
8724
+ server2.registerTool(
8725
+ "graph_analysis",
8726
+ {
8727
+ title: "Graph Analysis",
8728
+ description: 'Analyze vault link graph structure. Use analysis to pick the mode:\n- "orphans": Notes with no backlinks (disconnected content)\n- "dead_ends": Notes with backlinks but no outgoing links\n- "sources": Notes with outgoing links but no backlinks\n- "hubs": Highly connected notes (many links to/from)\n- "stale": Important notes (by backlink count) not recently modified\n- "immature": Notes scored by maturity (word count, links, frontmatter completeness, backlinks)\n- "evolution": Graph topology metrics over time (avg_degree, cluster_count, etc.)\n- "emerging_hubs": Entities growing fastest in connection count\n\nExample: graph_analysis({ analysis: "hubs", limit: 10 })\nExample: graph_analysis({ analysis: "stale", days: 30, min_backlinks: 3 })\nExample: graph_analysis({ analysis: "immature", folder: "projects", limit: 20 })\nExample: graph_analysis({ analysis: "evolution", days: 30 })\nExample: graph_analysis({ analysis: "emerging_hubs", days: 30 })',
8729
+ inputSchema: {
8730
+ analysis: z8.enum(["orphans", "dead_ends", "sources", "hubs", "stale", "immature", "evolution", "emerging_hubs"]).describe("Type of graph analysis to perform"),
8731
+ folder: z8.string().optional().describe("Limit to notes in this folder (orphans, dead_ends, sources)"),
8732
+ min_links: z8.coerce.number().default(5).describe("Minimum total connections for hubs"),
8733
+ min_backlinks: z8.coerce.number().default(1).describe("Minimum backlinks (dead_ends, stale)"),
8734
+ min_outlinks: z8.coerce.number().default(1).describe("Minimum outlinks (sources)"),
8735
+ days: z8.coerce.number().optional().describe("Notes not modified in this many days (stale, required)"),
8736
+ limit: z8.coerce.number().default(50).describe("Maximum number of results to return"),
8737
+ offset: z8.coerce.number().default(0).describe("Number of results to skip (for pagination)")
8738
+ }
8739
+ },
8740
+ async ({ analysis, folder, min_links, min_backlinks, min_outlinks, days, limit: requestedLimit, offset }) => {
8741
+ requireIndex();
8742
+ const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
8743
+ const index = getIndex();
8744
+ switch (analysis) {
8745
+ case "orphans": {
8746
+ const allOrphans = findOrphanNotes(index, folder);
8747
+ const orphans = allOrphans.slice(offset, offset + limit);
8748
+ return {
8749
+ content: [{ type: "text", text: JSON.stringify({
8750
+ analysis: "orphans",
8751
+ orphan_count: allOrphans.length,
8752
+ returned_count: orphans.length,
8753
+ folder,
8754
+ orphans: orphans.map((o) => ({
8755
+ path: o.path,
8756
+ title: o.title,
8757
+ modified: o.modified.toISOString()
8758
+ }))
8439
8759
  }, null, 2) }]
8440
8760
  };
8441
8761
  }
8442
- case "validate": {
8443
- if (!params.schema) {
8444
- return {
8445
- content: [{ type: "text", text: JSON.stringify({
8446
- error: "schema parameter is required for validate analysis"
8447
- }, null, 2) }]
8448
- };
8449
- }
8450
- const result = validateFrontmatter(
8451
- index,
8452
- params.schema,
8453
- params.folder
8454
- );
8762
+ case "dead_ends": {
8763
+ const allResults = findDeadEnds(index, folder, min_backlinks);
8764
+ const result = allResults.slice(offset, offset + limit);
8455
8765
  return {
8456
8766
  content: [{ type: "text", text: JSON.stringify({
8457
- notes_with_issues: result.length,
8458
- results: result
8767
+ analysis: "dead_ends",
8768
+ criteria: { folder, min_backlinks },
8769
+ total_count: allResults.length,
8770
+ returned_count: result.length,
8771
+ dead_ends: result
8459
8772
  }, null, 2) }]
8460
8773
  };
8461
8774
  }
8462
- case "missing": {
8463
- if (!params.folder_schemas) {
8775
+ case "sources": {
8776
+ const allResults = findSources(index, folder, min_outlinks);
8777
+ const result = allResults.slice(offset, offset + limit);
8778
+ return {
8779
+ content: [{ type: "text", text: JSON.stringify({
8780
+ analysis: "sources",
8781
+ criteria: { folder, min_outlinks },
8782
+ total_count: allResults.length,
8783
+ returned_count: result.length,
8784
+ sources: result
8785
+ }, null, 2) }]
8786
+ };
8787
+ }
8788
+ case "hubs": {
8789
+ const allHubs = findHubNotes(index, min_links);
8790
+ const hubs = allHubs.slice(offset, offset + limit);
8791
+ return {
8792
+ content: [{ type: "text", text: JSON.stringify({
8793
+ analysis: "hubs",
8794
+ hub_count: allHubs.length,
8795
+ returned_count: hubs.length,
8796
+ min_links,
8797
+ hubs: hubs.map((h) => ({
8798
+ path: h.path,
8799
+ title: h.title,
8800
+ backlink_count: h.backlink_count,
8801
+ forward_link_count: h.forward_link_count,
8802
+ total_connections: h.total_connections
8803
+ }))
8804
+ }, null, 2) }]
8805
+ };
8806
+ }
8807
+ case "stale": {
8808
+ if (days === void 0) {
8464
8809
  return {
8465
8810
  content: [{ type: "text", text: JSON.stringify({
8466
- error: "folder_schemas parameter is required for missing analysis"
8811
+ error: "days parameter is required for stale analysis"
8467
8812
  }, null, 2) }]
8468
8813
  };
8469
8814
  }
8470
- const result = findMissingFrontmatter(
8471
- index,
8472
- params.folder_schemas
8473
- );
8815
+ const result = getStaleNotes(index, days, min_backlinks).slice(0, limit);
8474
8816
  return {
8475
8817
  content: [{ type: "text", text: JSON.stringify({
8476
- notes_with_missing_fields: result.length,
8477
- results: result
8818
+ analysis: "stale",
8819
+ criteria: { days, min_backlinks },
8820
+ count: result.length,
8821
+ notes: result.map((n) => ({
8822
+ ...n,
8823
+ modified: n.modified.toISOString()
8824
+ }))
8478
8825
  }, null, 2) }]
8479
8826
  };
8480
8827
  }
8481
- case "conventions": {
8482
- const result = inferFolderConventions(
8483
- index,
8484
- params.folder,
8485
- params.min_confidence ?? 0.5
8828
+ case "immature": {
8829
+ const vaultPath2 = getVaultPath();
8830
+ const allNotes = Array.from(index.notes.values()).filter(
8831
+ (note) => !folder || note.path.startsWith(folder + "/") || note.path.substring(0, note.path.lastIndexOf("/")) === folder
8486
8832
  );
8833
+ const conventions = inferFolderConventions(index, folder, 0.5);
8834
+ const expectedFields = conventions.inferred_fields.map((f) => f.name);
8835
+ const scored = allNotes.map((note) => {
8836
+ let wordCount = 0;
8837
+ try {
8838
+ const content = fs14.readFileSync(path13.join(vaultPath2, note.path), "utf-8");
8839
+ const body = content.replace(/^---[\s\S]*?---\n?/, "");
8840
+ wordCount = body.split(/\s+/).filter((w) => w.length > 0).length;
8841
+ } catch {
8842
+ }
8843
+ const wordScore = wordCount < 100 ? 0 : wordCount < 500 ? 0.5 : 1;
8844
+ const outlinkCount = note.outlinks.length;
8845
+ const outlinkScore = outlinkCount === 0 ? 0 : outlinkCount <= 3 ? 0.5 : 1;
8846
+ let frontmatterScore = 0;
8847
+ if (expectedFields.length > 0) {
8848
+ const existingFields = Object.keys(note.frontmatter);
8849
+ const presentCount = expectedFields.filter((f) => existingFields.includes(f)).length;
8850
+ frontmatterScore = presentCount / expectedFields.length;
8851
+ } else {
8852
+ frontmatterScore = 1;
8853
+ }
8854
+ const normalizedTitle = note.title.toLowerCase();
8855
+ const backlinks = index.backlinks.get(normalizedTitle) || [];
8856
+ const backlinkCount = backlinks.length;
8857
+ const backlinkScore = backlinkCount === 0 ? 0 : backlinkCount <= 2 ? 0.5 : 1;
8858
+ const maturity = (wordScore + outlinkScore + frontmatterScore + backlinkScore) / 4;
8859
+ return {
8860
+ path: note.path,
8861
+ title: note.title,
8862
+ maturity_score: Math.round(maturity * 100) / 100,
8863
+ components: {
8864
+ word_count: { value: wordCount, score: wordScore },
8865
+ outlinks: { value: outlinkCount, score: outlinkScore },
8866
+ frontmatter: { value: `${expectedFields.length > 0 ? Math.round(frontmatterScore * 100) : 100}%`, score: Math.round(frontmatterScore * 100) / 100 },
8867
+ backlinks: { value: backlinkCount, score: backlinkScore }
8868
+ },
8869
+ modified: note.modified.toISOString()
8870
+ };
8871
+ });
8872
+ scored.sort((a, b) => a.maturity_score - b.maturity_score);
8873
+ const total = scored.length;
8874
+ const paginated = scored.slice(offset, offset + limit);
8487
8875
  return {
8488
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
8876
+ content: [{ type: "text", text: JSON.stringify({
8877
+ analysis: "immature",
8878
+ criteria: { folder: folder || null },
8879
+ total_count: total,
8880
+ returned_count: paginated.length,
8881
+ expected_fields: expectedFields,
8882
+ notes: paginated
8883
+ }, null, 2) }]
8489
8884
  };
8490
8885
  }
8491
- case "incomplete": {
8492
- const result = findIncompleteNotes(
8493
- index,
8494
- params.folder,
8495
- params.min_frequency ?? 0.7,
8496
- limit,
8497
- params.offset ?? 0
8498
- );
8886
+ case "evolution": {
8887
+ const db3 = getStateDb?.();
8888
+ if (!db3) {
8889
+ return {
8890
+ content: [{ type: "text", text: JSON.stringify({
8891
+ error: "StateDb not available \u2014 graph evolution requires persistent state"
8892
+ }, null, 2) }]
8893
+ };
8894
+ }
8895
+ const daysBack = days ?? 30;
8896
+ const evolutions = getGraphEvolution(db3, daysBack);
8499
8897
  return {
8500
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
8898
+ content: [{ type: "text", text: JSON.stringify({
8899
+ analysis: "evolution",
8900
+ days_back: daysBack,
8901
+ metrics: evolutions
8902
+ }, null, 2) }]
8501
8903
  };
8502
8904
  }
8503
- case "suggest_values": {
8504
- if (!params.field) {
8905
+ case "emerging_hubs": {
8906
+ const db3 = getStateDb?.();
8907
+ if (!db3) {
8505
8908
  return {
8506
8909
  content: [{ type: "text", text: JSON.stringify({
8507
- error: "field parameter is required for suggest_values analysis"
8910
+ error: "StateDb not available \u2014 emerging hubs requires persistent state"
8508
8911
  }, null, 2) }]
8509
8912
  };
8510
8913
  }
8511
- const result = suggestFieldValues(index, params.field, {
8512
- folder: params.folder,
8513
- existing_frontmatter: params.existing_frontmatter
8514
- });
8914
+ const daysBack = days ?? 30;
8915
+ const hubs = getEmergingHubs(db3, daysBack);
8515
8916
  return {
8516
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
8917
+ content: [{ type: "text", text: JSON.stringify({
8918
+ analysis: "emerging_hubs",
8919
+ days_back: daysBack,
8920
+ count: hubs.length,
8921
+ hubs
8922
+ }, null, 2) }]
8517
8923
  };
8518
8924
  }
8519
8925
  }
@@ -8521,45 +8927,392 @@ function registerVaultSchemaTools(server2, getIndex, getVaultPath) {
8521
8927
  );
8522
8928
  }
8523
8929
 
8524
- // src/tools/read/noteIntelligence.ts
8525
- import { z as z10 } from "zod";
8930
+ // src/tools/read/vaultSchema.ts
8931
+ import { z as z9 } from "zod";
8526
8932
 
8527
- // src/tools/read/bidirectional.ts
8528
- import * as fs13 from "fs/promises";
8529
- import * as path13 from "path";
8530
- import matter3 from "gray-matter";
8531
- var PROSE_PATTERN_REGEX = /^([A-Za-z][A-Za-z0-9 _-]*):\s*(?:\[\[([^\]]+)\]\]|"([^"]+)"|([^\n]+?))\s*$/gm;
8532
- var CODE_BLOCK_REGEX2 = /```[\s\S]*?```|`[^`\n]+`/g;
8533
- var WIKILINK_REGEX2 = /\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]+)?\]\]/g;
8534
- async function readFileContent2(notePath, vaultPath2) {
8535
- const fullPath = path13.join(vaultPath2, notePath);
8536
- try {
8537
- return await fs13.readFile(fullPath, "utf-8");
8538
- } catch {
8539
- return null;
8540
- }
8541
- }
8542
- function getBodyContent(content) {
8543
- try {
8544
- const parsed = matter3(content);
8545
- const frontmatterMatch = content.match(/^---\n[\s\S]*?\n---\n?/);
8546
- const bodyStartLine = frontmatterMatch ? frontmatterMatch[0].split("\n").length : 1;
8547
- return { body: parsed.content, bodyStartLine };
8548
- } catch {
8549
- return { body: content, bodyStartLine: 1 };
8550
- }
8551
- }
8552
- function removeCodeBlocks(content) {
8553
- return content.replace(CODE_BLOCK_REGEX2, (match) => {
8554
- const newlines = (match.match(/\n/g) || []).length;
8555
- return "\n".repeat(newlines);
8556
- });
8933
+ // src/tools/read/frontmatter.ts
8934
+ function getValueType2(value) {
8935
+ if (value === null) return "null";
8936
+ if (value === void 0) return "undefined";
8937
+ if (Array.isArray(value)) return "array";
8938
+ if (value instanceof Date) return "date";
8939
+ return typeof value;
8557
8940
  }
8558
- function extractWikilinksFromValue(value) {
8559
- if (typeof value === "string") {
8560
- const matches = [];
8561
- let match;
8562
- WIKILINK_REGEX2.lastIndex = 0;
8941
+ function getFrontmatterSchema(index) {
8942
+ const fieldMap = /* @__PURE__ */ new Map();
8943
+ let notesWithFrontmatter = 0;
8944
+ for (const note of index.notes.values()) {
8945
+ const fm = note.frontmatter;
8946
+ if (!fm || Object.keys(fm).length === 0) continue;
8947
+ notesWithFrontmatter++;
8948
+ for (const [key, value] of Object.entries(fm)) {
8949
+ if (!fieldMap.has(key)) {
8950
+ fieldMap.set(key, {
8951
+ types: /* @__PURE__ */ new Set(),
8952
+ count: 0,
8953
+ examples: [],
8954
+ notes: []
8955
+ });
8956
+ }
8957
+ const info = fieldMap.get(key);
8958
+ info.count++;
8959
+ info.types.add(getValueType2(value));
8960
+ if (info.examples.length < 5) {
8961
+ const valueStr = JSON.stringify(value);
8962
+ const existingStrs = info.examples.map((e) => JSON.stringify(e));
8963
+ if (!existingStrs.includes(valueStr)) {
8964
+ info.examples.push(value);
8965
+ }
8966
+ }
8967
+ if (info.notes.length < 5) {
8968
+ info.notes.push(note.path);
8969
+ }
8970
+ }
8971
+ }
8972
+ const fields = Array.from(fieldMap.entries()).map(([name, info]) => ({
8973
+ name,
8974
+ types: Array.from(info.types),
8975
+ count: info.count,
8976
+ examples: info.examples,
8977
+ notes_sample: info.notes
8978
+ })).sort((a, b) => b.count - a.count);
8979
+ return {
8980
+ total_notes: index.notes.size,
8981
+ notes_with_frontmatter: notesWithFrontmatter,
8982
+ field_count: fields.length,
8983
+ fields
8984
+ };
8985
+ }
8986
+ function getFieldValues(index, fieldName) {
8987
+ const valueMap = /* @__PURE__ */ new Map();
8988
+ let totalWithField = 0;
8989
+ for (const note of index.notes.values()) {
8990
+ const value = note.frontmatter[fieldName];
8991
+ if (value === void 0) continue;
8992
+ totalWithField++;
8993
+ const values = Array.isArray(value) ? value : [value];
8994
+ for (const v of values) {
8995
+ const key = JSON.stringify(v);
8996
+ if (!valueMap.has(key)) {
8997
+ valueMap.set(key, {
8998
+ value: v,
8999
+ count: 0,
9000
+ notes: []
9001
+ });
9002
+ }
9003
+ const info = valueMap.get(key);
9004
+ info.count++;
9005
+ info.notes.push(note.path);
9006
+ }
9007
+ }
9008
+ const valuesList = Array.from(valueMap.values()).sort((a, b) => b.count - a.count);
9009
+ return {
9010
+ field: fieldName,
9011
+ total_notes_with_field: totalWithField,
9012
+ unique_values: valuesList.length,
9013
+ values: valuesList
9014
+ };
9015
+ }
9016
+ function findFrontmatterInconsistencies(index) {
9017
+ const schema = getFrontmatterSchema(index);
9018
+ const inconsistencies = [];
9019
+ for (const field of schema.fields) {
9020
+ if (field.types.length > 1) {
9021
+ const examples = [];
9022
+ for (const note of index.notes.values()) {
9023
+ const value = note.frontmatter[field.name];
9024
+ if (value === void 0) continue;
9025
+ const type = getValueType2(value);
9026
+ if (!examples.some((e) => e.type === type)) {
9027
+ examples.push({
9028
+ type,
9029
+ value,
9030
+ note: note.path
9031
+ });
9032
+ }
9033
+ if (examples.length >= field.types.length) break;
9034
+ }
9035
+ inconsistencies.push({
9036
+ field: field.name,
9037
+ types_found: field.types,
9038
+ examples
9039
+ });
9040
+ }
9041
+ }
9042
+ return inconsistencies;
9043
+ }
9044
+ function validateFrontmatter(index, schema, folder) {
9045
+ const results = [];
9046
+ for (const note of index.notes.values()) {
9047
+ if (folder && !note.path.startsWith(folder)) continue;
9048
+ const issues = [];
9049
+ for (const [fieldName, fieldSchema] of Object.entries(schema)) {
9050
+ const value = note.frontmatter[fieldName];
9051
+ if (fieldSchema.required && value === void 0) {
9052
+ issues.push({
9053
+ field: fieldName,
9054
+ issue: "missing",
9055
+ expected: "value required"
9056
+ });
9057
+ continue;
9058
+ }
9059
+ if (value === void 0) continue;
9060
+ if (fieldSchema.type) {
9061
+ const actualType = getValueType2(value);
9062
+ const allowedTypes = Array.isArray(fieldSchema.type) ? fieldSchema.type : [fieldSchema.type];
9063
+ if (!allowedTypes.includes(actualType)) {
9064
+ issues.push({
9065
+ field: fieldName,
9066
+ issue: "wrong_type",
9067
+ expected: allowedTypes.join(" | "),
9068
+ actual: actualType
9069
+ });
9070
+ }
9071
+ }
9072
+ if (fieldSchema.values) {
9073
+ const valueStr = JSON.stringify(value);
9074
+ const allowedStrs = fieldSchema.values.map((v) => JSON.stringify(v));
9075
+ if (!allowedStrs.includes(valueStr)) {
9076
+ issues.push({
9077
+ field: fieldName,
9078
+ issue: "invalid_value",
9079
+ expected: fieldSchema.values.map((v) => String(v)).join(" | "),
9080
+ actual: String(value)
9081
+ });
9082
+ }
9083
+ }
9084
+ }
9085
+ if (issues.length > 0) {
9086
+ results.push({
9087
+ path: note.path,
9088
+ issues
9089
+ });
9090
+ }
9091
+ }
9092
+ return results;
9093
+ }
9094
+ function findMissingFrontmatter(index, folderSchemas) {
9095
+ const results = [];
9096
+ for (const note of index.notes.values()) {
9097
+ for (const [folder, requiredFields] of Object.entries(folderSchemas)) {
9098
+ if (!note.path.startsWith(folder)) continue;
9099
+ const missing = requiredFields.filter(
9100
+ (field) => note.frontmatter[field] === void 0
9101
+ );
9102
+ if (missing.length > 0) {
9103
+ results.push({
9104
+ path: note.path,
9105
+ folder,
9106
+ missing_fields: missing
9107
+ });
9108
+ }
9109
+ }
9110
+ }
9111
+ return results;
9112
+ }
9113
+
9114
+ // src/tools/read/vaultSchema.ts
9115
+ function registerVaultSchemaTools(server2, getIndex, getVaultPath) {
9116
+ server2.registerTool(
9117
+ "vault_schema",
9118
+ {
9119
+ title: "Vault Schema",
9120
+ description: 'Analyze and validate vault frontmatter schema. Use analysis to pick the mode:\n- "overview": Schema of all frontmatter fields across the vault\n- "field_values": All unique values for a specific field\n- "inconsistencies": Fields with multiple types across notes\n- "validate": Validate notes against a provided schema\n- "missing": Find notes missing expected fields by folder\n- "conventions": Auto-detect metadata conventions for a folder\n- "incomplete": Find notes missing expected fields (inferred)\n- "suggest_values": Suggest values for a field based on usage\n- "contradictions": Find conflicting frontmatter values across notes referencing the same entity\n\nExample: vault_schema({ analysis: "field_values", field: "status" })\nExample: vault_schema({ analysis: "conventions", folder: "projects" })\nExample: vault_schema({ analysis: "contradictions", entity: "project alpha" })',
9121
+ inputSchema: {
9122
+ analysis: z9.enum([
9123
+ "overview",
9124
+ "field_values",
9125
+ "inconsistencies",
9126
+ "validate",
9127
+ "missing",
9128
+ "conventions",
9129
+ "incomplete",
9130
+ "suggest_values",
9131
+ "contradictions"
9132
+ ]).describe("Type of schema analysis to perform"),
9133
+ field: z9.string().optional().describe("Field name (field_values, suggest_values)"),
9134
+ entity: z9.string().optional().describe("Entity name to scope contradiction detection to (contradictions mode)"),
9135
+ folder: z9.string().optional().describe("Folder to scope analysis to"),
9136
+ schema: z9.record(z9.object({
9137
+ required: z9.boolean().optional().describe("Whether field is required"),
9138
+ type: z9.union([z9.string(), z9.array(z9.string())]).optional().describe("Expected type(s)"),
9139
+ values: z9.array(z9.unknown()).optional().describe("Allowed values")
9140
+ })).optional().describe("Schema to validate against (validate mode)"),
9141
+ folder_schemas: z9.record(z9.array(z9.string())).optional().describe("Map of folder paths to required fields (missing mode)"),
9142
+ min_confidence: z9.coerce.number().min(0).max(1).optional().describe("Minimum confidence threshold (conventions)"),
9143
+ min_frequency: z9.coerce.number().min(0).max(1).optional().describe("Minimum field frequency (incomplete)"),
9144
+ existing_frontmatter: z9.record(z9.unknown()).optional().describe("Existing frontmatter for context (suggest_values)"),
9145
+ limit: z9.coerce.number().default(50).describe("Maximum results to return"),
9146
+ offset: z9.coerce.number().default(0).describe("Number of results to skip")
9147
+ }
9148
+ },
9149
+ async (params) => {
9150
+ requireIndex();
9151
+ const limit = Math.min(params.limit ?? 50, MAX_LIMIT);
9152
+ const index = getIndex();
9153
+ switch (params.analysis) {
9154
+ case "overview": {
9155
+ const result = getFrontmatterSchema(index);
9156
+ return {
9157
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
9158
+ };
9159
+ }
9160
+ case "field_values": {
9161
+ if (!params.field) {
9162
+ return {
9163
+ content: [{ type: "text", text: JSON.stringify({
9164
+ error: "field parameter is required for field_values analysis"
9165
+ }, null, 2) }]
9166
+ };
9167
+ }
9168
+ const result = getFieldValues(index, params.field);
9169
+ return {
9170
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
9171
+ };
9172
+ }
9173
+ case "inconsistencies": {
9174
+ const result = findFrontmatterInconsistencies(index);
9175
+ return {
9176
+ content: [{ type: "text", text: JSON.stringify({
9177
+ inconsistency_count: result.length,
9178
+ inconsistencies: result
9179
+ }, null, 2) }]
9180
+ };
9181
+ }
9182
+ case "validate": {
9183
+ if (!params.schema) {
9184
+ return {
9185
+ content: [{ type: "text", text: JSON.stringify({
9186
+ error: "schema parameter is required for validate analysis"
9187
+ }, null, 2) }]
9188
+ };
9189
+ }
9190
+ const result = validateFrontmatter(
9191
+ index,
9192
+ params.schema,
9193
+ params.folder
9194
+ );
9195
+ return {
9196
+ content: [{ type: "text", text: JSON.stringify({
9197
+ notes_with_issues: result.length,
9198
+ results: result
9199
+ }, null, 2) }]
9200
+ };
9201
+ }
9202
+ case "missing": {
9203
+ if (!params.folder_schemas) {
9204
+ return {
9205
+ content: [{ type: "text", text: JSON.stringify({
9206
+ error: "folder_schemas parameter is required for missing analysis"
9207
+ }, null, 2) }]
9208
+ };
9209
+ }
9210
+ const result = findMissingFrontmatter(
9211
+ index,
9212
+ params.folder_schemas
9213
+ );
9214
+ return {
9215
+ content: [{ type: "text", text: JSON.stringify({
9216
+ notes_with_missing_fields: result.length,
9217
+ results: result
9218
+ }, null, 2) }]
9219
+ };
9220
+ }
9221
+ case "conventions": {
9222
+ const result = inferFolderConventions(
9223
+ index,
9224
+ params.folder,
9225
+ params.min_confidence ?? 0.5
9226
+ );
9227
+ return {
9228
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
9229
+ };
9230
+ }
9231
+ case "incomplete": {
9232
+ const result = findIncompleteNotes(
9233
+ index,
9234
+ params.folder,
9235
+ params.min_frequency ?? 0.7,
9236
+ limit,
9237
+ params.offset ?? 0
9238
+ );
9239
+ return {
9240
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
9241
+ };
9242
+ }
9243
+ case "suggest_values": {
9244
+ if (!params.field) {
9245
+ return {
9246
+ content: [{ type: "text", text: JSON.stringify({
9247
+ error: "field parameter is required for suggest_values analysis"
9248
+ }, null, 2) }]
9249
+ };
9250
+ }
9251
+ const result = suggestFieldValues(index, params.field, {
9252
+ folder: params.folder,
9253
+ existing_frontmatter: params.existing_frontmatter
9254
+ });
9255
+ return {
9256
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
9257
+ };
9258
+ }
9259
+ case "contradictions": {
9260
+ const allContradictions = findContradictions(index, params.entity);
9261
+ const paginated = allContradictions.slice(params.offset ?? 0, (params.offset ?? 0) + limit);
9262
+ return {
9263
+ content: [{ type: "text", text: JSON.stringify({
9264
+ analysis: "contradictions",
9265
+ entity: params.entity || null,
9266
+ total_count: allContradictions.length,
9267
+ returned_count: paginated.length,
9268
+ contradictions: paginated
9269
+ }, null, 2) }]
9270
+ };
9271
+ }
9272
+ }
9273
+ }
9274
+ );
9275
+ }
9276
+
9277
+ // src/tools/read/noteIntelligence.ts
9278
+ import { z as z10 } from "zod";
9279
+
9280
+ // src/tools/read/bidirectional.ts
9281
+ import * as fs15 from "fs/promises";
9282
+ import * as path14 from "path";
9283
+ import matter3 from "gray-matter";
9284
+ var PROSE_PATTERN_REGEX = /^([A-Za-z][A-Za-z0-9 _-]*):\s*(?:\[\[([^\]]+)\]\]|"([^"]+)"|([^\n]+?))\s*$/gm;
9285
+ var CODE_BLOCK_REGEX2 = /```[\s\S]*?```|`[^`\n]+`/g;
9286
+ var WIKILINK_REGEX2 = /\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]+)?\]\]/g;
9287
+ async function readFileContent2(notePath, vaultPath2) {
9288
+ const fullPath = path14.join(vaultPath2, notePath);
9289
+ try {
9290
+ return await fs15.readFile(fullPath, "utf-8");
9291
+ } catch {
9292
+ return null;
9293
+ }
9294
+ }
9295
+ function getBodyContent(content) {
9296
+ try {
9297
+ const parsed = matter3(content);
9298
+ const frontmatterMatch = content.match(/^---\n[\s\S]*?\n---\n?/);
9299
+ const bodyStartLine = frontmatterMatch ? frontmatterMatch[0].split("\n").length : 1;
9300
+ return { body: parsed.content, bodyStartLine };
9301
+ } catch {
9302
+ return { body: content, bodyStartLine: 1 };
9303
+ }
9304
+ }
9305
+ function removeCodeBlocks(content) {
9306
+ return content.replace(CODE_BLOCK_REGEX2, (match) => {
9307
+ const newlines = (match.match(/\n/g) || []).length;
9308
+ return "\n".repeat(newlines);
9309
+ });
9310
+ }
9311
+ function extractWikilinksFromValue(value) {
9312
+ if (typeof value === "string") {
9313
+ const matches = [];
9314
+ let match;
9315
+ WIKILINK_REGEX2.lastIndex = 0;
8563
9316
  while ((match = WIKILINK_REGEX2.exec(value)) !== null) {
8564
9317
  matches.push(match[1].trim());
8565
9318
  }
@@ -8801,21 +9554,21 @@ async function validateCrossLayer(index, notePath, vaultPath2) {
8801
9554
  }
8802
9555
 
8803
9556
  // src/tools/read/computed.ts
8804
- import * as fs14 from "fs/promises";
8805
- import * as path14 from "path";
9557
+ import * as fs16 from "fs/promises";
9558
+ import * as path15 from "path";
8806
9559
  import matter4 from "gray-matter";
8807
9560
  async function readFileContent3(notePath, vaultPath2) {
8808
- const fullPath = path14.join(vaultPath2, notePath);
9561
+ const fullPath = path15.join(vaultPath2, notePath);
8809
9562
  try {
8810
- return await fs14.readFile(fullPath, "utf-8");
9563
+ return await fs16.readFile(fullPath, "utf-8");
8811
9564
  } catch {
8812
9565
  return null;
8813
9566
  }
8814
9567
  }
8815
9568
  async function getFileStats(notePath, vaultPath2) {
8816
- const fullPath = path14.join(vaultPath2, notePath);
9569
+ const fullPath = path15.join(vaultPath2, notePath);
8817
9570
  try {
8818
- const stats = await fs14.stat(fullPath);
9571
+ const stats = await fs16.stat(fullPath);
8819
9572
  return {
8820
9573
  modified: stats.mtime,
8821
9574
  created: stats.birthtime
@@ -9025,8 +9778,8 @@ function registerNoteIntelligenceTools(server2, getIndex, getVaultPath) {
9025
9778
  // src/tools/write/mutations.ts
9026
9779
  init_writer();
9027
9780
  import { z as z11 } from "zod";
9028
- import fs17 from "fs/promises";
9029
- import path17 from "path";
9781
+ import fs19 from "fs/promises";
9782
+ import path18 from "path";
9030
9783
 
9031
9784
  // src/core/write/validator.ts
9032
9785
  var TIMESTAMP_PATTERN = /^\*\*\d{2}:\d{2}\*\*/;
@@ -9228,8 +9981,8 @@ function runValidationPipeline(content, format, options = {}) {
9228
9981
 
9229
9982
  // src/core/write/mutation-helpers.ts
9230
9983
  init_writer();
9231
- import fs16 from "fs/promises";
9232
- import path16 from "path";
9984
+ import fs18 from "fs/promises";
9985
+ import path17 from "path";
9233
9986
  init_constants();
9234
9987
  init_writer();
9235
9988
  function formatMcpResult(result) {
@@ -9278,9 +10031,9 @@ async function handleGitCommit(vaultPath2, notePath, commit, prefix) {
9278
10031
  return info;
9279
10032
  }
9280
10033
  async function ensureFileExists(vaultPath2, notePath) {
9281
- const fullPath = path16.join(vaultPath2, notePath);
10034
+ const fullPath = path17.join(vaultPath2, notePath);
9282
10035
  try {
9283
- await fs16.access(fullPath);
10036
+ await fs18.access(fullPath);
9284
10037
  return null;
9285
10038
  } catch {
9286
10039
  return errorResult(notePath, `File not found: ${notePath}`);
@@ -9378,10 +10131,10 @@ async function withVaultFrontmatter(options, operation) {
9378
10131
 
9379
10132
  // src/tools/write/mutations.ts
9380
10133
  async function createNoteFromTemplate(vaultPath2, notePath, config) {
9381
- const fullPath = path17.join(vaultPath2, notePath);
9382
- await fs17.mkdir(path17.dirname(fullPath), { recursive: true });
10134
+ const fullPath = path18.join(vaultPath2, notePath);
10135
+ await fs19.mkdir(path18.dirname(fullPath), { recursive: true });
9383
10136
  const templates = config.templates || {};
9384
- const filename = path17.basename(notePath, ".md").toLowerCase();
10137
+ const filename = path18.basename(notePath, ".md").toLowerCase();
9385
10138
  let templatePath;
9386
10139
  const dailyPattern = /^\d{4}-\d{2}-\d{2}/;
9387
10140
  const weeklyPattern = /^\d{4}-W\d{2}/;
@@ -9402,10 +10155,10 @@ async function createNoteFromTemplate(vaultPath2, notePath, config) {
9402
10155
  let templateContent;
9403
10156
  if (templatePath) {
9404
10157
  try {
9405
- const absTemplatePath = path17.join(vaultPath2, templatePath);
9406
- templateContent = await fs17.readFile(absTemplatePath, "utf-8");
10158
+ const absTemplatePath = path18.join(vaultPath2, templatePath);
10159
+ templateContent = await fs19.readFile(absTemplatePath, "utf-8");
9407
10160
  } catch {
9408
- const title = path17.basename(notePath, ".md");
10161
+ const title = path18.basename(notePath, ".md");
9409
10162
  templateContent = `---
9410
10163
  ---
9411
10164
 
@@ -9414,7 +10167,7 @@ async function createNoteFromTemplate(vaultPath2, notePath, config) {
9414
10167
  templatePath = void 0;
9415
10168
  }
9416
10169
  } else {
9417
- const title = path17.basename(notePath, ".md");
10170
+ const title = path18.basename(notePath, ".md");
9418
10171
  templateContent = `---
9419
10172
  ---
9420
10173
 
@@ -9423,8 +10176,8 @@ async function createNoteFromTemplate(vaultPath2, notePath, config) {
9423
10176
  }
9424
10177
  const now = /* @__PURE__ */ new Date();
9425
10178
  const dateStr = now.toISOString().split("T")[0];
9426
- templateContent = templateContent.replace(/\{\{date\}\}/g, dateStr).replace(/\{\{title\}\}/g, path17.basename(notePath, ".md"));
9427
- await fs17.writeFile(fullPath, templateContent, "utf-8");
10179
+ templateContent = templateContent.replace(/\{\{date\}\}/g, dateStr).replace(/\{\{title\}\}/g, path18.basename(notePath, ".md"));
10180
+ await fs19.writeFile(fullPath, templateContent, "utf-8");
9428
10181
  return { created: true, templateUsed: templatePath };
9429
10182
  }
9430
10183
  function registerMutationTools(server2, vaultPath2, getConfig = () => ({})) {
@@ -9455,9 +10208,9 @@ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", con
9455
10208
  let noteCreated = false;
9456
10209
  let templateUsed;
9457
10210
  if (create_if_missing) {
9458
- const fullPath = path17.join(vaultPath2, notePath);
10211
+ const fullPath = path18.join(vaultPath2, notePath);
9459
10212
  try {
9460
- await fs17.access(fullPath);
10213
+ await fs19.access(fullPath);
9461
10214
  } catch {
9462
10215
  const config = getConfig();
9463
10216
  const result = await createNoteFromTemplate(vaultPath2, notePath, config);
@@ -9892,8 +10645,8 @@ Example: vault_update_frontmatter({ path: "projects/alpha.md", frontmatter: { st
9892
10645
  // src/tools/write/notes.ts
9893
10646
  init_writer();
9894
10647
  import { z as z14 } from "zod";
9895
- import fs18 from "fs/promises";
9896
- import path18 from "path";
10648
+ import fs20 from "fs/promises";
10649
+ import path19 from "path";
9897
10650
  function registerNoteTools(server2, vaultPath2, getIndex) {
9898
10651
  server2.tool(
9899
10652
  "vault_create_note",
@@ -9916,23 +10669,23 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
9916
10669
  if (!validatePath(vaultPath2, notePath)) {
9917
10670
  return formatMcpResult(errorResult(notePath, "Invalid path: path traversal not allowed"));
9918
10671
  }
9919
- const fullPath = path18.join(vaultPath2, notePath);
10672
+ const fullPath = path19.join(vaultPath2, notePath);
9920
10673
  const existsCheck = await ensureFileExists(vaultPath2, notePath);
9921
10674
  if (existsCheck === null && !overwrite) {
9922
10675
  return formatMcpResult(errorResult(notePath, `File already exists: ${notePath}. Use overwrite=true to replace.`));
9923
10676
  }
9924
- const dir = path18.dirname(fullPath);
9925
- await fs18.mkdir(dir, { recursive: true });
10677
+ const dir = path19.dirname(fullPath);
10678
+ await fs20.mkdir(dir, { recursive: true });
9926
10679
  let effectiveContent = content;
9927
10680
  let effectiveFrontmatter = frontmatter;
9928
10681
  if (template) {
9929
- const templatePath = path18.join(vaultPath2, template);
10682
+ const templatePath = path19.join(vaultPath2, template);
9930
10683
  try {
9931
- const raw = await fs18.readFile(templatePath, "utf-8");
10684
+ const raw = await fs20.readFile(templatePath, "utf-8");
9932
10685
  const matter9 = (await import("gray-matter")).default;
9933
10686
  const parsed = matter9(raw);
9934
10687
  const dateStr = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
9935
- const title = path18.basename(notePath, ".md");
10688
+ const title = path19.basename(notePath, ".md");
9936
10689
  let templateContent = parsed.content.replace(/\{\{date\}\}/g, dateStr).replace(/\{\{title\}\}/g, title);
9937
10690
  if (content) {
9938
10691
  templateContent = templateContent.trimEnd() + "\n\n" + content;
@@ -9944,7 +10697,7 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
9944
10697
  }
9945
10698
  }
9946
10699
  const warnings = [];
9947
- const noteName = path18.basename(notePath, ".md");
10700
+ const noteName = path19.basename(notePath, ".md");
9948
10701
  const existingAliases = Array.isArray(effectiveFrontmatter?.aliases) ? effectiveFrontmatter.aliases.filter((a) => typeof a === "string") : [];
9949
10702
  const preflight = checkPreflightSimilarity(noteName);
9950
10703
  if (preflight.existingEntity) {
@@ -10061,8 +10814,8 @@ ${sources}`;
10061
10814
  }
10062
10815
  return formatMcpResult(errorResult(notePath, previewLines.join("\n")));
10063
10816
  }
10064
- const fullPath = path18.join(vaultPath2, notePath);
10065
- await fs18.unlink(fullPath);
10817
+ const fullPath = path19.join(vaultPath2, notePath);
10818
+ await fs20.unlink(fullPath);
10066
10819
  const gitInfo = await handleGitCommit(vaultPath2, notePath, commit, "[Flywheel:Delete]");
10067
10820
  const message = backlinkWarning ? `Deleted note: ${notePath}
10068
10821
 
@@ -10080,8 +10833,8 @@ Warning: ${backlinkWarning}` : `Deleted note: ${notePath}`;
10080
10833
  // src/tools/write/move-notes.ts
10081
10834
  init_writer();
10082
10835
  import { z as z15 } from "zod";
10083
- import fs19 from "fs/promises";
10084
- import path19 from "path";
10836
+ import fs21 from "fs/promises";
10837
+ import path20 from "path";
10085
10838
  import matter6 from "gray-matter";
10086
10839
  function escapeRegex(str) {
10087
10840
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -10100,16 +10853,16 @@ function extractWikilinks2(content) {
10100
10853
  return wikilinks;
10101
10854
  }
10102
10855
  function getTitleFromPath(filePath) {
10103
- return path19.basename(filePath, ".md");
10856
+ return path20.basename(filePath, ".md");
10104
10857
  }
10105
10858
  async function findBacklinks(vaultPath2, targetTitle, targetAliases) {
10106
10859
  const results = [];
10107
10860
  const allTargets = [targetTitle, ...targetAliases].map((t) => t.toLowerCase());
10108
10861
  async function scanDir(dir) {
10109
10862
  const files = [];
10110
- const entries = await fs19.readdir(dir, { withFileTypes: true });
10863
+ const entries = await fs21.readdir(dir, { withFileTypes: true });
10111
10864
  for (const entry of entries) {
10112
- const fullPath = path19.join(dir, entry.name);
10865
+ const fullPath = path20.join(dir, entry.name);
10113
10866
  if (entry.isDirectory() && !entry.name.startsWith(".")) {
10114
10867
  files.push(...await scanDir(fullPath));
10115
10868
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
@@ -10120,8 +10873,8 @@ async function findBacklinks(vaultPath2, targetTitle, targetAliases) {
10120
10873
  }
10121
10874
  const allFiles = await scanDir(vaultPath2);
10122
10875
  for (const filePath of allFiles) {
10123
- const relativePath = path19.relative(vaultPath2, filePath);
10124
- const content = await fs19.readFile(filePath, "utf-8");
10876
+ const relativePath = path20.relative(vaultPath2, filePath);
10877
+ const content = await fs21.readFile(filePath, "utf-8");
10125
10878
  const wikilinks = extractWikilinks2(content);
10126
10879
  const matchingLinks = [];
10127
10880
  for (const link of wikilinks) {
@@ -10140,8 +10893,8 @@ async function findBacklinks(vaultPath2, targetTitle, targetAliases) {
10140
10893
  return results;
10141
10894
  }
10142
10895
  async function updateBacklinksInFile(vaultPath2, filePath, oldTitles, newTitle) {
10143
- const fullPath = path19.join(vaultPath2, filePath);
10144
- const raw = await fs19.readFile(fullPath, "utf-8");
10896
+ const fullPath = path20.join(vaultPath2, filePath);
10897
+ const raw = await fs21.readFile(fullPath, "utf-8");
10145
10898
  const parsed = matter6(raw);
10146
10899
  let content = parsed.content;
10147
10900
  let totalUpdated = 0;
@@ -10207,10 +10960,10 @@ function registerMoveNoteTools(server2, vaultPath2) {
10207
10960
  };
10208
10961
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
10209
10962
  }
10210
- const oldFullPath = path19.join(vaultPath2, oldPath);
10211
- const newFullPath = path19.join(vaultPath2, newPath);
10963
+ const oldFullPath = path20.join(vaultPath2, oldPath);
10964
+ const newFullPath = path20.join(vaultPath2, newPath);
10212
10965
  try {
10213
- await fs19.access(oldFullPath);
10966
+ await fs21.access(oldFullPath);
10214
10967
  } catch {
10215
10968
  const result2 = {
10216
10969
  success: false,
@@ -10220,7 +10973,7 @@ function registerMoveNoteTools(server2, vaultPath2) {
10220
10973
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
10221
10974
  }
10222
10975
  try {
10223
- await fs19.access(newFullPath);
10976
+ await fs21.access(newFullPath);
10224
10977
  const result2 = {
10225
10978
  success: false,
10226
10979
  message: `Destination already exists: ${newPath}`,
@@ -10229,7 +10982,7 @@ function registerMoveNoteTools(server2, vaultPath2) {
10229
10982
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
10230
10983
  } catch {
10231
10984
  }
10232
- const sourceContent = await fs19.readFile(oldFullPath, "utf-8");
10985
+ const sourceContent = await fs21.readFile(oldFullPath, "utf-8");
10233
10986
  const parsed = matter6(sourceContent);
10234
10987
  const aliases = extractAliases2(parsed.data);
10235
10988
  const oldTitle = getTitleFromPath(oldPath);
@@ -10258,9 +11011,9 @@ function registerMoveNoteTools(server2, vaultPath2) {
10258
11011
  }
10259
11012
  }
10260
11013
  }
10261
- const destDir = path19.dirname(newFullPath);
10262
- await fs19.mkdir(destDir, { recursive: true });
10263
- await fs19.rename(oldFullPath, newFullPath);
11014
+ const destDir = path20.dirname(newFullPath);
11015
+ await fs21.mkdir(destDir, { recursive: true });
11016
+ await fs21.rename(oldFullPath, newFullPath);
10264
11017
  let gitCommit;
10265
11018
  let undoAvailable;
10266
11019
  let staleLockDetected;
@@ -10344,12 +11097,12 @@ function registerMoveNoteTools(server2, vaultPath2) {
10344
11097
  if (sanitizedTitle !== newTitle) {
10345
11098
  console.error(`[Flywheel] Title sanitized: "${newTitle}" \u2192 "${sanitizedTitle}"`);
10346
11099
  }
10347
- const fullPath = path19.join(vaultPath2, notePath);
10348
- const dir = path19.dirname(notePath);
10349
- const newPath = dir === "." ? `${sanitizedTitle}.md` : path19.join(dir, `${sanitizedTitle}.md`);
10350
- const newFullPath = path19.join(vaultPath2, newPath);
11100
+ const fullPath = path20.join(vaultPath2, notePath);
11101
+ const dir = path20.dirname(notePath);
11102
+ const newPath = dir === "." ? `${sanitizedTitle}.md` : path20.join(dir, `${sanitizedTitle}.md`);
11103
+ const newFullPath = path20.join(vaultPath2, newPath);
10351
11104
  try {
10352
- await fs19.access(fullPath);
11105
+ await fs21.access(fullPath);
10353
11106
  } catch {
10354
11107
  const result2 = {
10355
11108
  success: false,
@@ -10360,7 +11113,7 @@ function registerMoveNoteTools(server2, vaultPath2) {
10360
11113
  }
10361
11114
  if (fullPath !== newFullPath) {
10362
11115
  try {
10363
- await fs19.access(newFullPath);
11116
+ await fs21.access(newFullPath);
10364
11117
  const result2 = {
10365
11118
  success: false,
10366
11119
  message: `A note with this title already exists: ${newPath}`,
@@ -10370,7 +11123,7 @@ function registerMoveNoteTools(server2, vaultPath2) {
10370
11123
  } catch {
10371
11124
  }
10372
11125
  }
10373
- const sourceContent = await fs19.readFile(fullPath, "utf-8");
11126
+ const sourceContent = await fs21.readFile(fullPath, "utf-8");
10374
11127
  const parsed = matter6(sourceContent);
10375
11128
  const aliases = extractAliases2(parsed.data);
10376
11129
  const oldTitle = getTitleFromPath(notePath);
@@ -10399,7 +11152,7 @@ function registerMoveNoteTools(server2, vaultPath2) {
10399
11152
  }
10400
11153
  }
10401
11154
  if (fullPath !== newFullPath) {
10402
- await fs19.rename(fullPath, newFullPath);
11155
+ await fs21.rename(fullPath, newFullPath);
10403
11156
  }
10404
11157
  let gitCommit;
10405
11158
  let undoAvailable;
@@ -10572,8 +11325,8 @@ init_schema();
10572
11325
 
10573
11326
  // src/core/write/policy/parser.ts
10574
11327
  init_schema();
10575
- import fs20 from "fs/promises";
10576
- import path20 from "path";
11328
+ import fs22 from "fs/promises";
11329
+ import path21 from "path";
10577
11330
  import matter7 from "gray-matter";
10578
11331
  function parseYaml(content) {
10579
11332
  const parsed = matter7(`---
@@ -10598,7 +11351,7 @@ function parsePolicyString(yamlContent) {
10598
11351
  }
10599
11352
  async function loadPolicyFile(filePath) {
10600
11353
  try {
10601
- const content = await fs20.readFile(filePath, "utf-8");
11354
+ const content = await fs22.readFile(filePath, "utf-8");
10602
11355
  return parsePolicyString(content);
10603
11356
  } catch (error) {
10604
11357
  if (error.code === "ENOENT") {
@@ -10622,15 +11375,15 @@ async function loadPolicyFile(filePath) {
10622
11375
  }
10623
11376
  }
10624
11377
  async function loadPolicy(vaultPath2, policyName) {
10625
- const policiesDir = path20.join(vaultPath2, ".claude", "policies");
10626
- const policyPath = path20.join(policiesDir, `${policyName}.yaml`);
11378
+ const policiesDir = path21.join(vaultPath2, ".claude", "policies");
11379
+ const policyPath = path21.join(policiesDir, `${policyName}.yaml`);
10627
11380
  try {
10628
- await fs20.access(policyPath);
11381
+ await fs22.access(policyPath);
10629
11382
  return loadPolicyFile(policyPath);
10630
11383
  } catch {
10631
- const ymlPath = path20.join(policiesDir, `${policyName}.yml`);
11384
+ const ymlPath = path21.join(policiesDir, `${policyName}.yml`);
10632
11385
  try {
10633
- await fs20.access(ymlPath);
11386
+ await fs22.access(ymlPath);
10634
11387
  return loadPolicyFile(ymlPath);
10635
11388
  } catch {
10636
11389
  return {
@@ -10768,8 +11521,8 @@ init_template();
10768
11521
  init_conditions();
10769
11522
  init_schema();
10770
11523
  init_writer();
10771
- import fs22 from "fs/promises";
10772
- import path22 from "path";
11524
+ import fs24 from "fs/promises";
11525
+ import path23 from "path";
10773
11526
  init_constants();
10774
11527
  async function executeStep(step, vaultPath2, context, conditionResults) {
10775
11528
  const { execute, reason } = shouldStepExecute(step.when, conditionResults);
@@ -10838,9 +11591,9 @@ async function executeAddToSection(params, vaultPath2, context) {
10838
11591
  const preserveListNesting = params.preserveListNesting !== false;
10839
11592
  const suggestOutgoingLinks = params.suggestOutgoingLinks !== false;
10840
11593
  const maxSuggestions = Number(params.maxSuggestions) || 3;
10841
- const fullPath = path22.join(vaultPath2, notePath);
11594
+ const fullPath = path23.join(vaultPath2, notePath);
10842
11595
  try {
10843
- await fs22.access(fullPath);
11596
+ await fs24.access(fullPath);
10844
11597
  } catch {
10845
11598
  return { success: false, message: `File not found: ${notePath}`, path: notePath };
10846
11599
  }
@@ -10878,9 +11631,9 @@ async function executeRemoveFromSection(params, vaultPath2) {
10878
11631
  const pattern = String(params.pattern || "");
10879
11632
  const mode = params.mode || "first";
10880
11633
  const useRegex = Boolean(params.useRegex);
10881
- const fullPath = path22.join(vaultPath2, notePath);
11634
+ const fullPath = path23.join(vaultPath2, notePath);
10882
11635
  try {
10883
- await fs22.access(fullPath);
11636
+ await fs24.access(fullPath);
10884
11637
  } catch {
10885
11638
  return { success: false, message: `File not found: ${notePath}`, path: notePath };
10886
11639
  }
@@ -10909,9 +11662,9 @@ async function executeReplaceInSection(params, vaultPath2, context) {
10909
11662
  const mode = params.mode || "first";
10910
11663
  const useRegex = Boolean(params.useRegex);
10911
11664
  const skipWikilinks = Boolean(params.skipWikilinks);
10912
- const fullPath = path22.join(vaultPath2, notePath);
11665
+ const fullPath = path23.join(vaultPath2, notePath);
10913
11666
  try {
10914
- await fs22.access(fullPath);
11667
+ await fs24.access(fullPath);
10915
11668
  } catch {
10916
11669
  return { success: false, message: `File not found: ${notePath}`, path: notePath };
10917
11670
  }
@@ -10952,16 +11705,16 @@ async function executeCreateNote(params, vaultPath2, context) {
10952
11705
  if (!validatePath(vaultPath2, notePath)) {
10953
11706
  return { success: false, message: "Invalid path: path traversal not allowed", path: notePath };
10954
11707
  }
10955
- const fullPath = path22.join(vaultPath2, notePath);
11708
+ const fullPath = path23.join(vaultPath2, notePath);
10956
11709
  try {
10957
- await fs22.access(fullPath);
11710
+ await fs24.access(fullPath);
10958
11711
  if (!overwrite) {
10959
11712
  return { success: false, message: `File already exists: ${notePath}`, path: notePath };
10960
11713
  }
10961
11714
  } catch {
10962
11715
  }
10963
- const dir = path22.dirname(fullPath);
10964
- await fs22.mkdir(dir, { recursive: true });
11716
+ const dir = path23.dirname(fullPath);
11717
+ await fs24.mkdir(dir, { recursive: true });
10965
11718
  const { content: processedContent } = maybeApplyWikilinks(content, skipWikilinks, notePath);
10966
11719
  await writeVaultFile(vaultPath2, notePath, processedContent, frontmatter);
10967
11720
  return {
@@ -10980,13 +11733,13 @@ async function executeDeleteNote(params, vaultPath2) {
10980
11733
  if (!validatePath(vaultPath2, notePath)) {
10981
11734
  return { success: false, message: "Invalid path: path traversal not allowed", path: notePath };
10982
11735
  }
10983
- const fullPath = path22.join(vaultPath2, notePath);
11736
+ const fullPath = path23.join(vaultPath2, notePath);
10984
11737
  try {
10985
- await fs22.access(fullPath);
11738
+ await fs24.access(fullPath);
10986
11739
  } catch {
10987
11740
  return { success: false, message: `File not found: ${notePath}`, path: notePath };
10988
11741
  }
10989
- await fs22.unlink(fullPath);
11742
+ await fs24.unlink(fullPath);
10990
11743
  return {
10991
11744
  success: true,
10992
11745
  message: `Deleted note: ${notePath}`,
@@ -10997,9 +11750,9 @@ async function executeToggleTask(params, vaultPath2) {
10997
11750
  const notePath = String(params.path || "");
10998
11751
  const task = String(params.task || "");
10999
11752
  const section = params.section ? String(params.section) : void 0;
11000
- const fullPath = path22.join(vaultPath2, notePath);
11753
+ const fullPath = path23.join(vaultPath2, notePath);
11001
11754
  try {
11002
- await fs22.access(fullPath);
11755
+ await fs24.access(fullPath);
11003
11756
  } catch {
11004
11757
  return { success: false, message: `File not found: ${notePath}`, path: notePath };
11005
11758
  }
@@ -11040,9 +11793,9 @@ async function executeAddTask(params, vaultPath2, context) {
11040
11793
  const completed = Boolean(params.completed);
11041
11794
  const skipWikilinks = Boolean(params.skipWikilinks);
11042
11795
  const preserveListNesting = params.preserveListNesting !== false;
11043
- const fullPath = path22.join(vaultPath2, notePath);
11796
+ const fullPath = path23.join(vaultPath2, notePath);
11044
11797
  try {
11045
- await fs22.access(fullPath);
11798
+ await fs24.access(fullPath);
11046
11799
  } catch {
11047
11800
  return { success: false, message: `File not found: ${notePath}`, path: notePath };
11048
11801
  }
@@ -11077,9 +11830,9 @@ async function executeAddTask(params, vaultPath2, context) {
11077
11830
  async function executeUpdateFrontmatter(params, vaultPath2) {
11078
11831
  const notePath = String(params.path || "");
11079
11832
  const updates = params.frontmatter || {};
11080
- const fullPath = path22.join(vaultPath2, notePath);
11833
+ const fullPath = path23.join(vaultPath2, notePath);
11081
11834
  try {
11082
- await fs22.access(fullPath);
11835
+ await fs24.access(fullPath);
11083
11836
  } catch {
11084
11837
  return { success: false, message: `File not found: ${notePath}`, path: notePath };
11085
11838
  }
@@ -11099,9 +11852,9 @@ async function executeAddFrontmatterField(params, vaultPath2) {
11099
11852
  const notePath = String(params.path || "");
11100
11853
  const key = String(params.key || "");
11101
11854
  const value = params.value;
11102
- const fullPath = path22.join(vaultPath2, notePath);
11855
+ const fullPath = path23.join(vaultPath2, notePath);
11103
11856
  try {
11104
- await fs22.access(fullPath);
11857
+ await fs24.access(fullPath);
11105
11858
  } catch {
11106
11859
  return { success: false, message: `File not found: ${notePath}`, path: notePath };
11107
11860
  }
@@ -11259,15 +12012,15 @@ async function executePolicy(policy, vaultPath2, variables, commit = false) {
11259
12012
  async function rollbackChanges(vaultPath2, originalContents, filesModified) {
11260
12013
  for (const filePath of filesModified) {
11261
12014
  const original = originalContents.get(filePath);
11262
- const fullPath = path22.join(vaultPath2, filePath);
12015
+ const fullPath = path23.join(vaultPath2, filePath);
11263
12016
  if (original === null) {
11264
12017
  try {
11265
- await fs22.unlink(fullPath);
12018
+ await fs24.unlink(fullPath);
11266
12019
  } catch {
11267
12020
  }
11268
12021
  } else if (original !== void 0) {
11269
12022
  try {
11270
- await fs22.writeFile(fullPath, original);
12023
+ await fs24.writeFile(fullPath, original);
11271
12024
  } catch {
11272
12025
  }
11273
12026
  }
@@ -11313,27 +12066,27 @@ async function previewPolicy(policy, vaultPath2, variables) {
11313
12066
  }
11314
12067
 
11315
12068
  // src/core/write/policy/storage.ts
11316
- import fs23 from "fs/promises";
11317
- import path23 from "path";
12069
+ import fs25 from "fs/promises";
12070
+ import path24 from "path";
11318
12071
  function getPoliciesDir(vaultPath2) {
11319
- return path23.join(vaultPath2, ".claude", "policies");
12072
+ return path24.join(vaultPath2, ".claude", "policies");
11320
12073
  }
11321
12074
  async function ensurePoliciesDir(vaultPath2) {
11322
12075
  const dir = getPoliciesDir(vaultPath2);
11323
- await fs23.mkdir(dir, { recursive: true });
12076
+ await fs25.mkdir(dir, { recursive: true });
11324
12077
  }
11325
12078
  async function listPolicies(vaultPath2) {
11326
12079
  const dir = getPoliciesDir(vaultPath2);
11327
12080
  const policies = [];
11328
12081
  try {
11329
- const files = await fs23.readdir(dir);
12082
+ const files = await fs25.readdir(dir);
11330
12083
  for (const file of files) {
11331
12084
  if (!file.endsWith(".yaml") && !file.endsWith(".yml")) {
11332
12085
  continue;
11333
12086
  }
11334
- const filePath = path23.join(dir, file);
11335
- const stat3 = await fs23.stat(filePath);
11336
- const content = await fs23.readFile(filePath, "utf-8");
12087
+ const filePath = path24.join(dir, file);
12088
+ const stat3 = await fs25.stat(filePath);
12089
+ const content = await fs25.readFile(filePath, "utf-8");
11337
12090
  const metadata = extractPolicyMetadata(content);
11338
12091
  policies.push({
11339
12092
  name: metadata.name || file.replace(/\.ya?ml$/, ""),
@@ -11356,10 +12109,10 @@ async function writePolicyRaw(vaultPath2, policyName, content, overwrite = false
11356
12109
  const dir = getPoliciesDir(vaultPath2);
11357
12110
  await ensurePoliciesDir(vaultPath2);
11358
12111
  const filename = `${policyName}.yaml`;
11359
- const filePath = path23.join(dir, filename);
12112
+ const filePath = path24.join(dir, filename);
11360
12113
  if (!overwrite) {
11361
12114
  try {
11362
- await fs23.access(filePath);
12115
+ await fs25.access(filePath);
11363
12116
  return {
11364
12117
  success: false,
11365
12118
  path: filename,
@@ -11376,7 +12129,7 @@ async function writePolicyRaw(vaultPath2, policyName, content, overwrite = false
11376
12129
  message: `Invalid policy: ${validation.errors.map((e) => e.message).join("; ")}`
11377
12130
  };
11378
12131
  }
11379
- await fs23.writeFile(filePath, content, "utf-8");
12132
+ await fs25.writeFile(filePath, content, "utf-8");
11380
12133
  return {
11381
12134
  success: true,
11382
12135
  path: filename,
@@ -11899,8 +12652,8 @@ function registerPolicyTools(server2, vaultPath2) {
11899
12652
  import { z as z19 } from "zod";
11900
12653
 
11901
12654
  // src/core/write/tagRename.ts
11902
- import * as fs24 from "fs/promises";
11903
- import * as path24 from "path";
12655
+ import * as fs26 from "fs/promises";
12656
+ import * as path25 from "path";
11904
12657
  import matter8 from "gray-matter";
11905
12658
  import { getProtectedZones } from "@velvetmonkey/vault-core";
11906
12659
  function getNotesInFolder3(index, folder) {
@@ -12006,10 +12759,10 @@ async function renameTag(index, vaultPath2, oldTag, newTag, options) {
12006
12759
  const previews = [];
12007
12760
  let totalChanges = 0;
12008
12761
  for (const note of affectedNotes) {
12009
- const fullPath = path24.join(vaultPath2, note.path);
12762
+ const fullPath = path25.join(vaultPath2, note.path);
12010
12763
  let fileContent;
12011
12764
  try {
12012
- fileContent = await fs24.readFile(fullPath, "utf-8");
12765
+ fileContent = await fs26.readFile(fullPath, "utf-8");
12013
12766
  } catch {
12014
12767
  continue;
12015
12768
  }
@@ -12069,370 +12822,987 @@ async function renameTag(index, vaultPath2, oldTag, newTag, options) {
12069
12822
  fm.tags = newTags;
12070
12823
  }
12071
12824
  }
12072
- const { content: updatedContent, changes: contentChanges } = replaceInlineTags(
12073
- parsed.content,
12074
- cleanOld,
12075
- cleanNew,
12076
- renameChildren
12077
- );
12078
- preview.content_changes = contentChanges;
12079
- preview.total_changes = preview.frontmatter_changes.length + preview.content_changes.length;
12080
- totalChanges += preview.total_changes;
12081
- if (preview.total_changes > 0) {
12082
- previews.push(preview);
12083
- if (!dryRun) {
12084
- const newContent = matter8.stringify(updatedContent, fm);
12085
- await fs24.writeFile(fullPath, newContent, "utf-8");
12825
+ const { content: updatedContent, changes: contentChanges } = replaceInlineTags(
12826
+ parsed.content,
12827
+ cleanOld,
12828
+ cleanNew,
12829
+ renameChildren
12830
+ );
12831
+ preview.content_changes = contentChanges;
12832
+ preview.total_changes = preview.frontmatter_changes.length + preview.content_changes.length;
12833
+ totalChanges += preview.total_changes;
12834
+ if (preview.total_changes > 0) {
12835
+ previews.push(preview);
12836
+ if (!dryRun) {
12837
+ const newContent = matter8.stringify(updatedContent, fm);
12838
+ await fs26.writeFile(fullPath, newContent, "utf-8");
12839
+ }
12840
+ }
12841
+ }
12842
+ return {
12843
+ old_tag: cleanOld,
12844
+ new_tag: cleanNew,
12845
+ rename_children: renameChildren,
12846
+ dry_run: dryRun,
12847
+ affected_notes: previews.length,
12848
+ total_changes: totalChanges,
12849
+ previews
12850
+ };
12851
+ }
12852
+
12853
+ // src/tools/write/tags.ts
12854
+ function registerTagTools(server2, getIndex, getVaultPath) {
12855
+ server2.registerTool(
12856
+ "rename_tag",
12857
+ {
12858
+ title: "Rename Tag",
12859
+ description: "Bulk rename a tag across all notes (frontmatter and inline). Supports hierarchical rename (#project \u2192 #work also transforms #project/active \u2192 #work/active). Dry-run by default (preview only). Handles deduplication when new tag already exists.",
12860
+ inputSchema: {
12861
+ old_tag: z19.string().describe('Tag to rename (without #, e.g., "project")'),
12862
+ new_tag: z19.string().describe('New tag name (without #, e.g., "work")'),
12863
+ rename_children: z19.boolean().optional().describe("Also rename child tags (e.g., #project/active \u2192 #work/active). Default: true"),
12864
+ folder: z19.string().optional().describe('Limit to notes in this folder (e.g., "projects")'),
12865
+ dry_run: z19.boolean().optional().describe("Preview only, no changes (default: true)"),
12866
+ commit: z19.boolean().optional().describe("Commit changes to git (default: false)")
12867
+ }
12868
+ },
12869
+ async ({ old_tag, new_tag, rename_children, folder, dry_run, commit }) => {
12870
+ const index = getIndex();
12871
+ const vaultPath2 = getVaultPath();
12872
+ const result = await renameTag(index, vaultPath2, old_tag, new_tag, {
12873
+ rename_children: rename_children ?? true,
12874
+ folder,
12875
+ dry_run: dry_run ?? true,
12876
+ commit: commit ?? false
12877
+ });
12878
+ return {
12879
+ content: [
12880
+ {
12881
+ type: "text",
12882
+ text: JSON.stringify(result, null, 2)
12883
+ }
12884
+ ]
12885
+ };
12886
+ }
12887
+ );
12888
+ }
12889
+
12890
+ // src/tools/write/wikilinkFeedback.ts
12891
+ import { z as z20 } from "zod";
12892
+ function registerWikilinkFeedbackTools(server2, getStateDb) {
12893
+ server2.registerTool(
12894
+ "wikilink_feedback",
12895
+ {
12896
+ title: "Wikilink Feedback",
12897
+ description: 'Report and query wikilink accuracy feedback. Modes: "report" (record feedback), "list" (view recent feedback), "stats" (entity accuracy statistics). Entities with >=30% false positive rate (and >=10 samples) are auto-suppressed from future wikilink application.',
12898
+ inputSchema: {
12899
+ mode: z20.enum(["report", "list", "stats"]).describe("Operation mode"),
12900
+ entity: z20.string().optional().describe("Entity name (required for report mode, optional filter for list/stats)"),
12901
+ note_path: z20.string().optional().describe("Note path where the wikilink appeared (for report mode)"),
12902
+ context: z20.string().optional().describe("Surrounding text context (for report mode)"),
12903
+ correct: z20.boolean().optional().describe("Whether the wikilink was correct (for report mode)"),
12904
+ limit: z20.number().optional().describe("Max entries to return for list mode (default: 20)")
12905
+ }
12906
+ },
12907
+ async ({ mode, entity, note_path, context, correct, limit }) => {
12908
+ const stateDb2 = getStateDb();
12909
+ if (!stateDb2) {
12910
+ return {
12911
+ content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available" }) }]
12912
+ };
12913
+ }
12914
+ let result;
12915
+ switch (mode) {
12916
+ case "report": {
12917
+ if (!entity || correct === void 0) {
12918
+ return {
12919
+ content: [{ type: "text", text: JSON.stringify({ error: "entity and correct are required for report mode" }) }]
12920
+ };
12921
+ }
12922
+ recordFeedback(stateDb2, entity, context || "", note_path || "", correct);
12923
+ const suppressionUpdated = updateSuppressionList(stateDb2) > 0;
12924
+ result = {
12925
+ mode: "report",
12926
+ reported: {
12927
+ entity,
12928
+ correct,
12929
+ suppression_updated: suppressionUpdated
12930
+ },
12931
+ total_suppressed: getSuppressedCount(stateDb2)
12932
+ };
12933
+ break;
12934
+ }
12935
+ case "list": {
12936
+ const entries = getFeedback(stateDb2, entity, limit ?? 20);
12937
+ result = {
12938
+ mode: "list",
12939
+ entries,
12940
+ total_feedback: entries.length
12941
+ };
12942
+ break;
12943
+ }
12944
+ case "stats": {
12945
+ const stats = getEntityStats(stateDb2);
12946
+ result = {
12947
+ mode: "stats",
12948
+ stats,
12949
+ total_feedback: stats.reduce((sum, s) => sum + s.total, 0),
12950
+ total_suppressed: getSuppressedCount(stateDb2)
12951
+ };
12952
+ break;
12953
+ }
12954
+ }
12955
+ return {
12956
+ content: [
12957
+ {
12958
+ type: "text",
12959
+ text: JSON.stringify(result, null, 2)
12960
+ }
12961
+ ]
12962
+ };
12963
+ }
12964
+ );
12965
+ }
12966
+
12967
+ // src/tools/read/metrics.ts
12968
+ import { z as z21 } from "zod";
12969
+
12970
+ // src/core/shared/metrics.ts
12971
+ var ALL_METRICS = [
12972
+ "note_count",
12973
+ "link_count",
12974
+ "orphan_count",
12975
+ "tag_count",
12976
+ "entity_count",
12977
+ "avg_links_per_note",
12978
+ "link_density",
12979
+ "connected_ratio",
12980
+ "wikilink_accuracy",
12981
+ "wikilink_feedback_volume",
12982
+ "wikilink_suppressed_count"
12983
+ ];
12984
+ function computeMetrics(index, stateDb2) {
12985
+ const noteCount = index.notes.size;
12986
+ let linkCount = 0;
12987
+ for (const note of index.notes.values()) {
12988
+ linkCount += note.outlinks.length;
12989
+ }
12990
+ const connectedNotes = /* @__PURE__ */ new Set();
12991
+ for (const [notePath, note] of index.notes) {
12992
+ if (note.outlinks.length > 0) {
12993
+ connectedNotes.add(notePath);
12994
+ }
12995
+ }
12996
+ for (const [target, backlinks] of index.backlinks) {
12997
+ for (const bl of backlinks) {
12998
+ connectedNotes.add(bl.source);
12999
+ }
13000
+ for (const note of index.notes.values()) {
13001
+ const normalizedTitle = note.title.toLowerCase();
13002
+ if (normalizedTitle === target.toLowerCase() || note.path.toLowerCase() === target.toLowerCase()) {
13003
+ connectedNotes.add(note.path);
12086
13004
  }
12087
13005
  }
12088
13006
  }
13007
+ let orphanCount = 0;
13008
+ for (const [notePath, note] of index.notes) {
13009
+ if (!connectedNotes.has(notePath)) {
13010
+ orphanCount++;
13011
+ }
13012
+ }
13013
+ const tagCount = index.tags.size;
13014
+ const entityCount = index.entities.size;
13015
+ const avgLinksPerNote = noteCount > 0 ? linkCount / noteCount : 0;
13016
+ const possibleLinks = noteCount * (noteCount - 1);
13017
+ const linkDensity = possibleLinks > 0 ? linkCount / possibleLinks : 0;
13018
+ const connectedRatio = noteCount > 0 ? connectedNotes.size / noteCount : 0;
13019
+ let wikilinkAccuracy = 0;
13020
+ let wikilinkFeedbackVolume = 0;
13021
+ let wikilinkSuppressedCount = 0;
13022
+ if (stateDb2) {
13023
+ const entityStatsList = getEntityStats(stateDb2);
13024
+ wikilinkFeedbackVolume = entityStatsList.reduce((sum, s) => sum + s.total, 0);
13025
+ if (wikilinkFeedbackVolume > 0) {
13026
+ const totalCorrect = entityStatsList.reduce((sum, s) => sum + s.correct, 0);
13027
+ wikilinkAccuracy = Math.round(totalCorrect / wikilinkFeedbackVolume * 1e3) / 1e3;
13028
+ }
13029
+ wikilinkSuppressedCount = getSuppressedCount(stateDb2);
13030
+ }
12089
13031
  return {
12090
- old_tag: cleanOld,
12091
- new_tag: cleanNew,
12092
- rename_children: renameChildren,
12093
- dry_run: dryRun,
12094
- affected_notes: previews.length,
12095
- total_changes: totalChanges,
12096
- previews
13032
+ note_count: noteCount,
13033
+ link_count: linkCount,
13034
+ orphan_count: orphanCount,
13035
+ tag_count: tagCount,
13036
+ entity_count: entityCount,
13037
+ avg_links_per_note: Math.round(avgLinksPerNote * 100) / 100,
13038
+ link_density: Math.round(linkDensity * 1e4) / 1e4,
13039
+ connected_ratio: Math.round(connectedRatio * 1e3) / 1e3,
13040
+ wikilink_accuracy: wikilinkAccuracy,
13041
+ wikilink_feedback_volume: wikilinkFeedbackVolume,
13042
+ wikilink_suppressed_count: wikilinkSuppressedCount
12097
13043
  };
12098
13044
  }
13045
+ function recordMetrics(stateDb2, metrics) {
13046
+ const timestamp = Date.now();
13047
+ const insert = stateDb2.db.prepare(
13048
+ "INSERT INTO vault_metrics (timestamp, metric, value) VALUES (?, ?, ?)"
13049
+ );
13050
+ const transaction = stateDb2.db.transaction(() => {
13051
+ for (const [metric, value] of Object.entries(metrics)) {
13052
+ insert.run(timestamp, metric, value);
13053
+ }
13054
+ });
13055
+ transaction();
13056
+ }
13057
+ function getMetricHistory(stateDb2, metric, daysBack = 30) {
13058
+ const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1e3;
13059
+ let rows;
13060
+ if (metric) {
13061
+ rows = stateDb2.db.prepare(
13062
+ "SELECT timestamp, metric, value FROM vault_metrics WHERE metric = ? AND timestamp >= ? ORDER BY timestamp"
13063
+ ).all(metric, cutoff);
13064
+ } else {
13065
+ rows = stateDb2.db.prepare(
13066
+ "SELECT timestamp, metric, value FROM vault_metrics WHERE timestamp >= ? ORDER BY timestamp"
13067
+ ).all(cutoff);
13068
+ }
13069
+ return rows.map((r) => ({
13070
+ metric: r.metric,
13071
+ value: r.value,
13072
+ timestamp: r.timestamp
13073
+ }));
13074
+ }
13075
+ function computeTrends(stateDb2, currentMetrics, daysBack = 30) {
13076
+ const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1e3;
13077
+ const rows = stateDb2.db.prepare(`
13078
+ SELECT metric, value FROM vault_metrics
13079
+ WHERE timestamp >= ? AND timestamp <= ?
13080
+ GROUP BY metric
13081
+ HAVING timestamp = MIN(timestamp)
13082
+ `).all(cutoff, cutoff + 24 * 60 * 60 * 1e3);
13083
+ const previousValues = /* @__PURE__ */ new Map();
13084
+ for (const row of rows) {
13085
+ previousValues.set(row.metric, row.value);
13086
+ }
13087
+ if (previousValues.size === 0) {
13088
+ const fallbackRows = stateDb2.db.prepare(`
13089
+ SELECT metric, MIN(value) as value FROM vault_metrics
13090
+ WHERE timestamp >= ?
13091
+ GROUP BY metric
13092
+ HAVING timestamp = MIN(timestamp)
13093
+ `).all(cutoff);
13094
+ for (const row of fallbackRows) {
13095
+ previousValues.set(row.metric, row.value);
13096
+ }
13097
+ }
13098
+ const trends = [];
13099
+ for (const metricName of ALL_METRICS) {
13100
+ const current = currentMetrics[metricName] ?? 0;
13101
+ const previous = previousValues.get(metricName) ?? current;
13102
+ const delta = current - previous;
13103
+ const deltaPct = previous !== 0 ? Math.round(delta / previous * 1e4) / 100 : delta !== 0 ? 100 : 0;
13104
+ let direction = "stable";
13105
+ if (delta > 0) direction = "up";
13106
+ if (delta < 0) direction = "down";
13107
+ trends.push({
13108
+ metric: metricName,
13109
+ current,
13110
+ previous,
13111
+ delta,
13112
+ delta_percent: deltaPct,
13113
+ direction
13114
+ });
13115
+ }
13116
+ return trends;
13117
+ }
13118
+ function purgeOldMetrics(stateDb2, retentionDays = 90) {
13119
+ const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1e3;
13120
+ const result = stateDb2.db.prepare(
13121
+ "DELETE FROM vault_metrics WHERE timestamp < ?"
13122
+ ).run(cutoff);
13123
+ return result.changes;
13124
+ }
12099
13125
 
12100
- // src/tools/write/tags.ts
12101
- function registerTagTools(server2, getIndex, getVaultPath) {
13126
+ // src/tools/read/metrics.ts
13127
+ function registerMetricsTools(server2, getIndex, getStateDb) {
12102
13128
  server2.registerTool(
12103
- "rename_tag",
13129
+ "vault_growth",
12104
13130
  {
12105
- title: "Rename Tag",
12106
- description: "Bulk rename a tag across all notes (frontmatter and inline). Supports hierarchical rename (#project \u2192 #work also transforms #project/active \u2192 #work/active). Dry-run by default (preview only). Handles deduplication when new tag already exists.",
13131
+ title: "Vault Growth",
13132
+ description: 'Track vault growth over time. Modes: "current" (live snapshot), "history" (time series), "trends" (deltas vs N days ago), "index_activity" (rebuild history). Tracks 11 metrics: note_count, link_count, orphan_count, tag_count, entity_count, avg_links_per_note, link_density, connected_ratio, wikilink_accuracy, wikilink_feedback_volume, wikilink_suppressed_count.',
12107
13133
  inputSchema: {
12108
- old_tag: z19.string().describe('Tag to rename (without #, e.g., "project")'),
12109
- new_tag: z19.string().describe('New tag name (without #, e.g., "work")'),
12110
- rename_children: z19.boolean().optional().describe("Also rename child tags (e.g., #project/active \u2192 #work/active). Default: true"),
12111
- folder: z19.string().optional().describe('Limit to notes in this folder (e.g., "projects")'),
12112
- dry_run: z19.boolean().optional().describe("Preview only, no changes (default: true)"),
12113
- commit: z19.boolean().optional().describe("Commit changes to git (default: false)")
13134
+ mode: z21.enum(["current", "history", "trends", "index_activity"]).describe("Query mode: current snapshot, historical time series, trend analysis, or index rebuild activity"),
13135
+ metric: z21.string().optional().describe('Filter to specific metric (e.g., "note_count"). Omit for all metrics.'),
13136
+ days_back: z21.number().optional().describe("Number of days to look back for history/trends (default: 30)"),
13137
+ limit: z21.number().optional().describe("Number of recent events to return for index_activity mode (default: 20)")
12114
13138
  }
12115
13139
  },
12116
- async ({ old_tag, new_tag, rename_children, folder, dry_run, commit }) => {
13140
+ async ({ mode, metric, days_back, limit: eventLimit }) => {
12117
13141
  const index = getIndex();
12118
- const vaultPath2 = getVaultPath();
12119
- const result = await renameTag(index, vaultPath2, old_tag, new_tag, {
12120
- rename_children: rename_children ?? true,
12121
- folder,
12122
- dry_run: dry_run ?? true,
12123
- commit: commit ?? false
12124
- });
13142
+ const stateDb2 = getStateDb();
13143
+ const daysBack = days_back ?? 30;
13144
+ let result;
13145
+ switch (mode) {
13146
+ case "current": {
13147
+ const metrics = computeMetrics(index, stateDb2 ?? void 0);
13148
+ result = {
13149
+ mode: "current",
13150
+ metrics,
13151
+ recorded_at: Date.now()
13152
+ };
13153
+ break;
13154
+ }
13155
+ case "history": {
13156
+ if (!stateDb2) {
13157
+ return {
13158
+ content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available for historical queries" }) }]
13159
+ };
13160
+ }
13161
+ const history = getMetricHistory(stateDb2, metric, daysBack);
13162
+ result = {
13163
+ mode: "history",
13164
+ history
13165
+ };
13166
+ break;
13167
+ }
13168
+ case "trends": {
13169
+ if (!stateDb2) {
13170
+ return {
13171
+ content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available for trend analysis" }) }]
13172
+ };
13173
+ }
13174
+ const currentMetrics = computeMetrics(index, stateDb2);
13175
+ const trends = computeTrends(stateDb2, currentMetrics, daysBack);
13176
+ result = {
13177
+ mode: "trends",
13178
+ trends
13179
+ };
13180
+ break;
13181
+ }
13182
+ case "index_activity": {
13183
+ if (!stateDb2) {
13184
+ return {
13185
+ content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available for index activity queries" }) }]
13186
+ };
13187
+ }
13188
+ const summary = getIndexActivitySummary(stateDb2);
13189
+ const recentEvents = getRecentIndexEvents(stateDb2, eventLimit ?? 20);
13190
+ result = {
13191
+ mode: "index_activity",
13192
+ index_activity: { summary, recent_events: recentEvents }
13193
+ };
13194
+ break;
13195
+ }
13196
+ }
12125
13197
  return {
12126
13198
  content: [
12127
13199
  {
12128
13200
  type: "text",
12129
13201
  text: JSON.stringify(result, null, 2)
12130
13202
  }
12131
- ]
12132
- };
13203
+ ]
13204
+ };
13205
+ }
13206
+ );
13207
+ }
13208
+
13209
+ // src/tools/read/activity.ts
13210
+ import { z as z22 } from "zod";
13211
+
13212
+ // src/core/shared/toolTracking.ts
13213
+ function recordToolInvocation(stateDb2, event) {
13214
+ stateDb2.db.prepare(
13215
+ `INSERT INTO tool_invocations (timestamp, tool_name, session_id, note_paths, duration_ms, success)
13216
+ VALUES (?, ?, ?, ?, ?, ?)`
13217
+ ).run(
13218
+ Date.now(),
13219
+ event.tool_name,
13220
+ event.session_id ?? null,
13221
+ event.note_paths ? JSON.stringify(event.note_paths) : null,
13222
+ event.duration_ms ?? null,
13223
+ event.success !== false ? 1 : 0
13224
+ );
13225
+ }
13226
+ function rowToInvocation(row) {
13227
+ return {
13228
+ id: row.id,
13229
+ timestamp: row.timestamp,
13230
+ tool_name: row.tool_name,
13231
+ session_id: row.session_id,
13232
+ note_paths: row.note_paths ? JSON.parse(row.note_paths) : null,
13233
+ duration_ms: row.duration_ms,
13234
+ success: row.success === 1
13235
+ };
13236
+ }
13237
+ function getToolUsageSummary(stateDb2, daysBack = 30) {
13238
+ const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1e3;
13239
+ const rows = stateDb2.db.prepare(`
13240
+ SELECT
13241
+ tool_name,
13242
+ COUNT(*) as invocation_count,
13243
+ AVG(duration_ms) as avg_duration_ms,
13244
+ SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) * 1.0 / COUNT(*) as success_rate,
13245
+ MAX(timestamp) as last_used
13246
+ FROM tool_invocations
13247
+ WHERE timestamp >= ?
13248
+ GROUP BY tool_name
13249
+ ORDER BY invocation_count DESC
13250
+ `).all(cutoff);
13251
+ return rows.map((r) => ({
13252
+ tool_name: r.tool_name,
13253
+ invocation_count: r.invocation_count,
13254
+ avg_duration_ms: Math.round(r.avg_duration_ms ?? 0),
13255
+ success_rate: Math.round(r.success_rate * 1e3) / 1e3,
13256
+ last_used: r.last_used
13257
+ }));
13258
+ }
13259
+ function getNoteAccessFrequency(stateDb2, daysBack = 30) {
13260
+ const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1e3;
13261
+ const rows = stateDb2.db.prepare(`
13262
+ SELECT note_paths, tool_name, timestamp
13263
+ FROM tool_invocations
13264
+ WHERE timestamp >= ? AND note_paths IS NOT NULL
13265
+ ORDER BY timestamp DESC
13266
+ `).all(cutoff);
13267
+ const noteMap = /* @__PURE__ */ new Map();
13268
+ for (const row of rows) {
13269
+ let paths;
13270
+ try {
13271
+ paths = JSON.parse(row.note_paths);
13272
+ } catch {
13273
+ continue;
13274
+ }
13275
+ for (const p of paths) {
13276
+ const existing = noteMap.get(p);
13277
+ if (existing) {
13278
+ existing.access_count++;
13279
+ existing.last_accessed = Math.max(existing.last_accessed, row.timestamp);
13280
+ existing.tools.add(row.tool_name);
13281
+ } else {
13282
+ noteMap.set(p, {
13283
+ access_count: 1,
13284
+ last_accessed: row.timestamp,
13285
+ tools: /* @__PURE__ */ new Set([row.tool_name])
13286
+ });
13287
+ }
13288
+ }
13289
+ }
13290
+ return Array.from(noteMap.entries()).map(([path28, stats]) => ({
13291
+ path: path28,
13292
+ access_count: stats.access_count,
13293
+ last_accessed: stats.last_accessed,
13294
+ tools_used: Array.from(stats.tools)
13295
+ })).sort((a, b) => b.access_count - a.access_count);
13296
+ }
13297
+ function getSessionHistory(stateDb2, sessionId) {
13298
+ if (sessionId) {
13299
+ const rows2 = stateDb2.db.prepare(`
13300
+ SELECT * FROM tool_invocations
13301
+ WHERE session_id = ?
13302
+ ORDER BY timestamp
13303
+ `).all(sessionId);
13304
+ if (rows2.length === 0) return [];
13305
+ const tools = /* @__PURE__ */ new Set();
13306
+ const notes = /* @__PURE__ */ new Set();
13307
+ for (const row of rows2) {
13308
+ tools.add(row.tool_name);
13309
+ if (row.note_paths) {
13310
+ try {
13311
+ for (const p of JSON.parse(row.note_paths)) {
13312
+ notes.add(p);
13313
+ }
13314
+ } catch {
13315
+ }
13316
+ }
12133
13317
  }
12134
- );
13318
+ return [{
13319
+ session_id: sessionId,
13320
+ started_at: rows2[0].timestamp,
13321
+ last_activity: rows2[rows2.length - 1].timestamp,
13322
+ tool_count: rows2.length,
13323
+ unique_tools: Array.from(tools),
13324
+ notes_accessed: Array.from(notes)
13325
+ }];
13326
+ }
13327
+ const rows = stateDb2.db.prepare(`
13328
+ SELECT
13329
+ session_id,
13330
+ MIN(timestamp) as started_at,
13331
+ MAX(timestamp) as last_activity,
13332
+ COUNT(*) as tool_count
13333
+ FROM tool_invocations
13334
+ WHERE session_id IS NOT NULL
13335
+ GROUP BY session_id
13336
+ ORDER BY last_activity DESC
13337
+ LIMIT 20
13338
+ `).all();
13339
+ return rows.map((r) => ({
13340
+ session_id: r.session_id,
13341
+ started_at: r.started_at,
13342
+ last_activity: r.last_activity,
13343
+ tool_count: r.tool_count,
13344
+ unique_tools: [],
13345
+ notes_accessed: []
13346
+ }));
13347
+ }
13348
+ function getRecentInvocations(stateDb2, limit = 20) {
13349
+ const rows = stateDb2.db.prepare(
13350
+ "SELECT * FROM tool_invocations ORDER BY timestamp DESC LIMIT ?"
13351
+ ).all(limit);
13352
+ return rows.map(rowToInvocation);
13353
+ }
13354
+ function purgeOldInvocations(stateDb2, retentionDays = 90) {
13355
+ const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1e3;
13356
+ const result = stateDb2.db.prepare(
13357
+ "DELETE FROM tool_invocations WHERE timestamp < ?"
13358
+ ).run(cutoff);
13359
+ return result.changes;
12135
13360
  }
12136
13361
 
12137
- // src/tools/write/wikilinkFeedback.ts
12138
- import { z as z20 } from "zod";
12139
- function registerWikilinkFeedbackTools(server2, getStateDb) {
13362
+ // src/tools/read/activity.ts
13363
+ function registerActivityTools(server2, getStateDb, getSessionId2) {
12140
13364
  server2.registerTool(
12141
- "wikilink_feedback",
13365
+ "vault_activity",
12142
13366
  {
12143
- title: "Wikilink Feedback",
12144
- description: 'Report and query wikilink accuracy feedback. Modes: "report" (record feedback), "list" (view recent feedback), "stats" (entity accuracy statistics). Entities with >=30% false positive rate (and >=10 samples) are auto-suppressed from future wikilink application.',
13367
+ title: "Vault Activity",
13368
+ description: 'Track tool usage patterns and session activity. Modes:\n- "session": Current session summary (tools called, notes accessed)\n- "sessions": List of recent sessions\n- "note_access": Notes ranked by query frequency\n- "tool_usage": Tool usage patterns (most-used tools, avg duration)',
12145
13369
  inputSchema: {
12146
- mode: z20.enum(["report", "list", "stats"]).describe("Operation mode"),
12147
- entity: z20.string().optional().describe("Entity name (required for report mode, optional filter for list/stats)"),
12148
- note_path: z20.string().optional().describe("Note path where the wikilink appeared (for report mode)"),
12149
- context: z20.string().optional().describe("Surrounding text context (for report mode)"),
12150
- correct: z20.boolean().optional().describe("Whether the wikilink was correct (for report mode)"),
12151
- limit: z20.number().optional().describe("Max entries to return for list mode (default: 20)")
13370
+ mode: z22.enum(["session", "sessions", "note_access", "tool_usage"]).describe("Activity query mode"),
13371
+ session_id: z22.string().optional().describe("Specific session ID (for session mode, defaults to current)"),
13372
+ days_back: z22.number().optional().describe("Number of days to look back (default: 30)"),
13373
+ limit: z22.number().optional().describe("Maximum results to return (default: 20)")
12152
13374
  }
12153
13375
  },
12154
- async ({ mode, entity, note_path, context, correct, limit }) => {
13376
+ async ({ mode, session_id, days_back, limit: resultLimit }) => {
12155
13377
  const stateDb2 = getStateDb();
12156
13378
  if (!stateDb2) {
12157
13379
  return {
12158
13380
  content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available" }) }]
12159
13381
  };
12160
13382
  }
12161
- let result;
13383
+ const daysBack = days_back ?? 30;
13384
+ const limit = resultLimit ?? 20;
12162
13385
  switch (mode) {
12163
- case "report": {
12164
- if (!entity || correct === void 0) {
13386
+ case "session": {
13387
+ const sid = session_id ?? getSessionId2();
13388
+ if (!sid) {
12165
13389
  return {
12166
- content: [{ type: "text", text: JSON.stringify({ error: "entity and correct are required for report mode" }) }]
13390
+ content: [{ type: "text", text: JSON.stringify({ error: "No session ID available" }) }]
12167
13391
  };
12168
13392
  }
12169
- recordFeedback(stateDb2, entity, context || "", note_path || "", correct);
12170
- const suppressionUpdated = updateSuppressionList(stateDb2) > 0;
12171
- result = {
12172
- mode: "report",
12173
- reported: {
12174
- entity,
12175
- correct,
12176
- suppression_updated: suppressionUpdated
12177
- },
12178
- total_suppressed: getSuppressedCount(stateDb2)
13393
+ const sessions = getSessionHistory(stateDb2, sid);
13394
+ const recent = getRecentInvocations(stateDb2, limit);
13395
+ const sessionInvocations = recent.filter((r) => r.session_id === sid);
13396
+ return {
13397
+ content: [{ type: "text", text: JSON.stringify({
13398
+ mode: "session",
13399
+ session_id: sid,
13400
+ summary: sessions[0] ?? null,
13401
+ recent_invocations: sessionInvocations
13402
+ }, null, 2) }]
12179
13403
  };
12180
- break;
12181
13404
  }
12182
- case "list": {
12183
- const entries = getFeedback(stateDb2, entity, limit ?? 20);
12184
- result = {
12185
- mode: "list",
12186
- entries,
12187
- total_feedback: entries.length
13405
+ case "sessions": {
13406
+ const sessions = getSessionHistory(stateDb2);
13407
+ return {
13408
+ content: [{ type: "text", text: JSON.stringify({
13409
+ mode: "sessions",
13410
+ sessions: sessions.slice(0, limit)
13411
+ }, null, 2) }]
12188
13412
  };
12189
- break;
12190
13413
  }
12191
- case "stats": {
12192
- const stats = getEntityStats(stateDb2);
12193
- result = {
12194
- mode: "stats",
12195
- stats,
12196
- total_feedback: stats.reduce((sum, s) => sum + s.total, 0),
12197
- total_suppressed: getSuppressedCount(stateDb2)
13414
+ case "note_access": {
13415
+ const notes = getNoteAccessFrequency(stateDb2, daysBack);
13416
+ return {
13417
+ content: [{ type: "text", text: JSON.stringify({
13418
+ mode: "note_access",
13419
+ days_back: daysBack,
13420
+ notes: notes.slice(0, limit)
13421
+ }, null, 2) }]
13422
+ };
13423
+ }
13424
+ case "tool_usage": {
13425
+ const tools = getToolUsageSummary(stateDb2, daysBack);
13426
+ return {
13427
+ content: [{ type: "text", text: JSON.stringify({
13428
+ mode: "tool_usage",
13429
+ days_back: daysBack,
13430
+ tools: tools.slice(0, limit)
13431
+ }, null, 2) }]
12198
13432
  };
12199
- break;
12200
13433
  }
12201
13434
  }
12202
- return {
12203
- content: [
12204
- {
12205
- type: "text",
12206
- text: JSON.stringify(result, null, 2)
12207
- }
12208
- ]
12209
- };
12210
13435
  }
12211
13436
  );
12212
13437
  }
12213
13438
 
12214
- // src/tools/read/metrics.ts
12215
- import { z as z21 } from "zod";
13439
+ // src/tools/read/similarity.ts
13440
+ import { z as z23 } from "zod";
12216
13441
 
12217
- // src/core/shared/metrics.ts
12218
- var ALL_METRICS = [
12219
- "note_count",
12220
- "link_count",
12221
- "orphan_count",
12222
- "tag_count",
12223
- "entity_count",
12224
- "avg_links_per_note",
12225
- "link_density",
12226
- "connected_ratio",
12227
- "wikilink_accuracy",
12228
- "wikilink_feedback_volume",
12229
- "wikilink_suppressed_count"
12230
- ];
12231
- function computeMetrics(index, stateDb2) {
12232
- const noteCount = index.notes.size;
12233
- let linkCount = 0;
12234
- for (const note of index.notes.values()) {
12235
- linkCount += note.outlinks.length;
12236
- }
12237
- const connectedNotes = /* @__PURE__ */ new Set();
12238
- for (const [notePath, note] of index.notes) {
12239
- if (note.outlinks.length > 0) {
12240
- connectedNotes.add(notePath);
12241
- }
12242
- }
12243
- for (const [target, backlinks] of index.backlinks) {
12244
- for (const bl of backlinks) {
12245
- connectedNotes.add(bl.source);
12246
- }
12247
- for (const note of index.notes.values()) {
12248
- const normalizedTitle = note.title.toLowerCase();
12249
- if (normalizedTitle === target.toLowerCase() || note.path.toLowerCase() === target.toLowerCase()) {
12250
- connectedNotes.add(note.path);
12251
- }
12252
- }
12253
- }
12254
- let orphanCount = 0;
12255
- for (const [notePath, note] of index.notes) {
12256
- if (!connectedNotes.has(notePath)) {
12257
- orphanCount++;
12258
- }
12259
- }
12260
- const tagCount = index.tags.size;
12261
- const entityCount = index.entities.size;
12262
- const avgLinksPerNote = noteCount > 0 ? linkCount / noteCount : 0;
12263
- const possibleLinks = noteCount * (noteCount - 1);
12264
- const linkDensity = possibleLinks > 0 ? linkCount / possibleLinks : 0;
12265
- const connectedRatio = noteCount > 0 ? connectedNotes.size / noteCount : 0;
12266
- let wikilinkAccuracy = 0;
12267
- let wikilinkFeedbackVolume = 0;
12268
- let wikilinkSuppressedCount = 0;
12269
- if (stateDb2) {
12270
- const entityStatsList = getEntityStats(stateDb2);
12271
- wikilinkFeedbackVolume = entityStatsList.reduce((sum, s) => sum + s.total, 0);
12272
- if (wikilinkFeedbackVolume > 0) {
12273
- const totalCorrect = entityStatsList.reduce((sum, s) => sum + s.correct, 0);
12274
- wikilinkAccuracy = Math.round(totalCorrect / wikilinkFeedbackVolume * 1e3) / 1e3;
12275
- }
12276
- wikilinkSuppressedCount = getSuppressedCount(stateDb2);
13442
+ // src/core/read/similarity.ts
13443
+ import * as fs27 from "fs";
13444
+ import * as path26 from "path";
13445
+ var STOP_WORDS = /* @__PURE__ */ new Set([
13446
+ "the",
13447
+ "be",
13448
+ "to",
13449
+ "of",
13450
+ "and",
13451
+ "a",
13452
+ "in",
13453
+ "that",
13454
+ "have",
13455
+ "i",
13456
+ "it",
13457
+ "for",
13458
+ "not",
13459
+ "on",
13460
+ "with",
13461
+ "he",
13462
+ "as",
13463
+ "you",
13464
+ "do",
13465
+ "at",
13466
+ "this",
13467
+ "but",
13468
+ "his",
13469
+ "by",
13470
+ "from",
13471
+ "they",
13472
+ "we",
13473
+ "say",
13474
+ "her",
13475
+ "she",
13476
+ "or",
13477
+ "an",
13478
+ "will",
13479
+ "my",
13480
+ "one",
13481
+ "all",
13482
+ "would",
13483
+ "there",
13484
+ "their",
13485
+ "what",
13486
+ "so",
13487
+ "up",
13488
+ "out",
13489
+ "if",
13490
+ "about",
13491
+ "who",
13492
+ "get",
13493
+ "which",
13494
+ "go",
13495
+ "me",
13496
+ "when",
13497
+ "make",
13498
+ "can",
13499
+ "like",
13500
+ "time",
13501
+ "no",
13502
+ "just",
13503
+ "him",
13504
+ "know",
13505
+ "take",
13506
+ "people",
13507
+ "into",
13508
+ "year",
13509
+ "your",
13510
+ "good",
13511
+ "some",
13512
+ "could",
13513
+ "them",
13514
+ "see",
13515
+ "other",
13516
+ "than",
13517
+ "then",
13518
+ "now",
13519
+ "look",
13520
+ "only",
13521
+ "come",
13522
+ "its",
13523
+ "over",
13524
+ "think",
13525
+ "also",
13526
+ "back",
13527
+ "after",
13528
+ "use",
13529
+ "two",
13530
+ "how",
13531
+ "our",
13532
+ "work",
13533
+ "first",
13534
+ "well",
13535
+ "way",
13536
+ "even",
13537
+ "new",
13538
+ "want",
13539
+ "because",
13540
+ "any",
13541
+ "these",
13542
+ "give",
13543
+ "day",
13544
+ "most",
13545
+ "us",
13546
+ "is",
13547
+ "was",
13548
+ "are",
13549
+ "been",
13550
+ "has",
13551
+ "had",
13552
+ "did",
13553
+ "being",
13554
+ "were",
13555
+ "does",
13556
+ "done",
13557
+ "may",
13558
+ "should",
13559
+ "each",
13560
+ "much",
13561
+ "need",
13562
+ "very",
13563
+ "still",
13564
+ "between",
13565
+ "own"
13566
+ ]);
13567
+ function extractKeyTerms(content, maxTerms = 15) {
13568
+ const bodyMatch = content.match(/^---[\s\S]*?---\n([\s\S]*)$/);
13569
+ const body = bodyMatch ? bodyMatch[1] : content;
13570
+ const cleaned = body.replace(/```[\s\S]*?```/g, "").replace(/`[^`]+`/g, "").replace(/\[\[([^\]|]+)(\|[^\]]+)?\]\]/g, "$1").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/https?:\/\/\S+/g, "").replace(/[#*_~>|=-]+/g, " ").replace(/\d+/g, " ");
13571
+ const words = cleaned.toLowerCase().split(/\W+/).filter((w) => w.length > 2);
13572
+ const freq = /* @__PURE__ */ new Map();
13573
+ for (const w of words) {
13574
+ if (STOP_WORDS.has(w)) continue;
13575
+ freq.set(w, (freq.get(w) || 0) + 1);
13576
+ }
13577
+ return Array.from(freq.entries()).sort((a, b) => b[1] - a[1]).slice(0, maxTerms).map(([word]) => word);
13578
+ }
13579
+ function findSimilarNotes(db3, vaultPath2, index, sourcePath, options = {}) {
13580
+ const limit = options.limit ?? 10;
13581
+ const absPath = path26.join(vaultPath2, sourcePath);
13582
+ let content;
13583
+ try {
13584
+ content = fs27.readFileSync(absPath, "utf-8");
13585
+ } catch {
13586
+ return [];
12277
13587
  }
12278
- return {
12279
- note_count: noteCount,
12280
- link_count: linkCount,
12281
- orphan_count: orphanCount,
12282
- tag_count: tagCount,
12283
- entity_count: entityCount,
12284
- avg_links_per_note: Math.round(avgLinksPerNote * 100) / 100,
12285
- link_density: Math.round(linkDensity * 1e4) / 1e4,
12286
- connected_ratio: Math.round(connectedRatio * 1e3) / 1e3,
12287
- wikilink_accuracy: wikilinkAccuracy,
12288
- wikilink_feedback_volume: wikilinkFeedbackVolume,
12289
- wikilink_suppressed_count: wikilinkSuppressedCount
12290
- };
12291
- }
12292
- function recordMetrics(stateDb2, metrics) {
12293
- const timestamp = Date.now();
12294
- const insert = stateDb2.db.prepare(
12295
- "INSERT INTO vault_metrics (timestamp, metric, value) VALUES (?, ?, ?)"
12296
- );
12297
- const transaction = stateDb2.db.transaction(() => {
12298
- for (const [metric, value] of Object.entries(metrics)) {
12299
- insert.run(timestamp, metric, value);
13588
+ const terms = extractKeyTerms(content);
13589
+ if (terms.length === 0) return [];
13590
+ const query = terms.join(" OR ");
13591
+ try {
13592
+ const results = db3.prepare(`
13593
+ SELECT
13594
+ path,
13595
+ title,
13596
+ bm25(notes_fts) as score,
13597
+ snippet(notes_fts, 2, '[', ']', '...', 15) as snippet
13598
+ FROM notes_fts
13599
+ WHERE notes_fts MATCH ?
13600
+ ORDER BY rank
13601
+ LIMIT ?
13602
+ `).all(query, limit + 20);
13603
+ let filtered = results.filter((r) => r.path !== sourcePath);
13604
+ if (options.excludeLinked) {
13605
+ const note = index.notes.get(sourcePath);
13606
+ if (note) {
13607
+ const linkedPaths = /* @__PURE__ */ new Set();
13608
+ for (const link of note.outlinks) {
13609
+ const resolved = index.entities.get(link.target.toLowerCase());
13610
+ if (resolved) linkedPaths.add(resolved);
13611
+ }
13612
+ const normalizedTitle = note.title.toLowerCase();
13613
+ const backlinks = index.backlinks.get(normalizedTitle) || [];
13614
+ for (const bl of backlinks) {
13615
+ linkedPaths.add(bl.source);
13616
+ }
13617
+ filtered = filtered.filter((r) => !linkedPaths.has(r.path));
13618
+ }
12300
13619
  }
12301
- });
12302
- transaction();
12303
- }
12304
- function getMetricHistory(stateDb2, metric, daysBack = 30) {
12305
- const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1e3;
12306
- let rows;
12307
- if (metric) {
12308
- rows = stateDb2.db.prepare(
12309
- "SELECT timestamp, metric, value FROM vault_metrics WHERE metric = ? AND timestamp >= ? ORDER BY timestamp"
12310
- ).all(metric, cutoff);
12311
- } else {
12312
- rows = stateDb2.db.prepare(
12313
- "SELECT timestamp, metric, value FROM vault_metrics WHERE timestamp >= ? ORDER BY timestamp"
12314
- ).all(cutoff);
13620
+ return filtered.slice(0, limit).map((r) => ({
13621
+ path: r.path,
13622
+ title: r.title,
13623
+ score: Math.round(Math.abs(r.score) * 1e3) / 1e3,
13624
+ snippet: r.snippet
13625
+ }));
13626
+ } catch {
13627
+ return [];
12315
13628
  }
12316
- return rows.map((r) => ({
12317
- metric: r.metric,
12318
- value: r.value,
12319
- timestamp: r.timestamp
13629
+ }
13630
+ function getLinkedPaths(index, sourcePath) {
13631
+ const linkedPaths = /* @__PURE__ */ new Set();
13632
+ const note = index.notes.get(sourcePath);
13633
+ if (!note) return linkedPaths;
13634
+ for (const link of note.outlinks) {
13635
+ const resolved = index.entities.get(link.target.toLowerCase());
13636
+ if (resolved) linkedPaths.add(resolved);
13637
+ }
13638
+ const normalizedTitle = note.title.toLowerCase();
13639
+ const backlinks = index.backlinks.get(normalizedTitle) || [];
13640
+ for (const bl of backlinks) {
13641
+ linkedPaths.add(bl.source);
13642
+ }
13643
+ return linkedPaths;
13644
+ }
13645
+ async function findSemanticSimilarNotes(vaultPath2, index, sourcePath, options = {}) {
13646
+ const limit = options.limit ?? 10;
13647
+ if (!hasEmbeddingsIndex()) {
13648
+ await buildEmbeddingsIndex(vaultPath2);
13649
+ }
13650
+ const excludePaths = options.excludeLinked ? getLinkedPaths(index, sourcePath) : void 0;
13651
+ const results = await findSemanticallySimilar(sourcePath, limit, excludePaths);
13652
+ return results.map((r) => ({
13653
+ path: r.path,
13654
+ title: r.title,
13655
+ score: r.score,
13656
+ snippet: ""
13657
+ // Semantic results don't have snippets
12320
13658
  }));
12321
13659
  }
12322
- function computeTrends(stateDb2, currentMetrics, daysBack = 30) {
12323
- const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1e3;
12324
- const rows = stateDb2.db.prepare(`
12325
- SELECT metric, value FROM vault_metrics
12326
- WHERE timestamp >= ? AND timestamp <= ?
12327
- GROUP BY metric
12328
- HAVING timestamp = MIN(timestamp)
12329
- `).all(cutoff, cutoff + 24 * 60 * 60 * 1e3);
12330
- const previousValues = /* @__PURE__ */ new Map();
12331
- for (const row of rows) {
12332
- previousValues.set(row.metric, row.value);
12333
- }
12334
- if (previousValues.size === 0) {
12335
- const fallbackRows = stateDb2.db.prepare(`
12336
- SELECT metric, MIN(value) as value FROM vault_metrics
12337
- WHERE timestamp >= ?
12338
- GROUP BY metric
12339
- HAVING timestamp = MIN(timestamp)
12340
- `).all(cutoff);
12341
- for (const row of fallbackRows) {
12342
- previousValues.set(row.metric, row.value);
12343
- }
12344
- }
12345
- const trends = [];
12346
- for (const metricName of ALL_METRICS) {
12347
- const current = currentMetrics[metricName] ?? 0;
12348
- const previous = previousValues.get(metricName) ?? current;
12349
- const delta = current - previous;
12350
- const deltaPct = previous !== 0 ? Math.round(delta / previous * 1e4) / 100 : delta !== 0 ? 100 : 0;
12351
- let direction = "stable";
12352
- if (delta > 0) direction = "up";
12353
- if (delta < 0) direction = "down";
12354
- trends.push({
12355
- metric: metricName,
12356
- current,
12357
- previous,
12358
- delta,
12359
- delta_percent: deltaPct,
12360
- direction
13660
+ async function findHybridSimilarNotes(db3, vaultPath2, index, sourcePath, options = {}) {
13661
+ const limit = options.limit ?? 10;
13662
+ const bm25Results = findSimilarNotes(db3, vaultPath2, index, sourcePath, {
13663
+ limit: limit * 2,
13664
+ excludeLinked: options.excludeLinked
13665
+ });
13666
+ let semanticResults;
13667
+ try {
13668
+ semanticResults = await findSemanticSimilarNotes(vaultPath2, index, sourcePath, {
13669
+ limit: limit * 2,
13670
+ excludeLinked: options.excludeLinked
12361
13671
  });
13672
+ } catch {
13673
+ return bm25Results.slice(0, limit);
12362
13674
  }
12363
- return trends;
12364
- }
12365
- function purgeOldMetrics(stateDb2, retentionDays = 90) {
12366
- const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1e3;
12367
- const result = stateDb2.db.prepare(
12368
- "DELETE FROM vault_metrics WHERE timestamp < ?"
12369
- ).run(cutoff);
12370
- return result.changes;
13675
+ const rrfScores = reciprocalRankFusion(
13676
+ bm25Results.map((r) => ({ path: r.path })),
13677
+ semanticResults.map((r) => ({ path: r.path }))
13678
+ );
13679
+ const bm25Map = new Map(bm25Results.map((r) => [r.path, r]));
13680
+ const semanticMap = new Map(semanticResults.map((r) => [r.path, r]));
13681
+ const allPaths = /* @__PURE__ */ new Set([...bm25Results.map((r) => r.path), ...semanticResults.map((r) => r.path)]);
13682
+ const merged = Array.from(allPaths).map((p) => {
13683
+ const bm25 = bm25Map.get(p);
13684
+ const semantic = semanticMap.get(p);
13685
+ return {
13686
+ path: p,
13687
+ title: bm25?.title || semantic?.title || p.replace(/\.md$/, "").split("/").pop() || p,
13688
+ score: Math.round((rrfScores.get(p) || 0) * 1e4) / 1e4,
13689
+ snippet: bm25?.snippet || ""
13690
+ };
13691
+ });
13692
+ merged.sort((a, b) => b.score - a.score);
13693
+ return merged.slice(0, limit);
12371
13694
  }
12372
13695
 
12373
- // src/tools/read/metrics.ts
12374
- function registerMetricsTools(server2, getIndex, getStateDb) {
13696
+ // src/tools/read/similarity.ts
13697
+ function registerSimilarityTools(server2, getIndex, getVaultPath, getStateDb) {
12375
13698
  server2.registerTool(
12376
- "vault_growth",
13699
+ "find_similar",
12377
13700
  {
12378
- title: "Vault Growth",
12379
- description: 'Track vault growth over time. Modes: "current" (live snapshot), "history" (time series), "trends" (deltas vs N days ago). Tracks 11 metrics: note_count, link_count, orphan_count, tag_count, entity_count, avg_links_per_note, link_density, connected_ratio, wikilink_accuracy, wikilink_feedback_volume, wikilink_suppressed_count.',
13701
+ title: "Find Similar Notes",
13702
+ description: "Find notes similar to a given note using FTS5 keyword matching. When embeddings have been built (via init_semantic), automatically uses hybrid ranking (BM25 + embedding similarity via Reciprocal Rank Fusion). Use exclude_linked to filter out notes already connected via wikilinks.",
12380
13703
  inputSchema: {
12381
- mode: z21.enum(["current", "history", "trends"]).describe("Query mode: current snapshot, historical time series, or trend analysis"),
12382
- metric: z21.string().optional().describe('Filter to specific metric (e.g., "note_count"). Omit for all metrics.'),
12383
- days_back: z21.number().optional().describe("Number of days to look back for history/trends (default: 30)")
13704
+ path: z23.string().describe('Path to the source note (relative to vault root, e.g. "projects/alpha.md")'),
13705
+ limit: z23.number().optional().describe("Maximum number of similar notes to return (default: 10)"),
13706
+ exclude_linked: z23.boolean().optional().describe("Exclude notes already linked to/from the source note (default: true)")
12384
13707
  }
12385
13708
  },
12386
- async ({ mode, metric, days_back }) => {
13709
+ async ({ path: path28, limit, exclude_linked }) => {
12387
13710
  const index = getIndex();
13711
+ const vaultPath2 = getVaultPath();
12388
13712
  const stateDb2 = getStateDb();
12389
- const daysBack = days_back ?? 30;
12390
- let result;
12391
- switch (mode) {
12392
- case "current": {
12393
- const metrics = computeMetrics(index, stateDb2 ?? void 0);
12394
- result = {
12395
- mode: "current",
12396
- metrics,
12397
- recorded_at: Date.now()
12398
- };
12399
- break;
12400
- }
12401
- case "history": {
12402
- if (!stateDb2) {
12403
- return {
12404
- content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available for historical queries" }) }]
12405
- };
12406
- }
12407
- const history = getMetricHistory(stateDb2, metric, daysBack);
12408
- result = {
12409
- mode: "history",
12410
- history
12411
- };
12412
- break;
12413
- }
12414
- case "trends": {
12415
- if (!stateDb2) {
12416
- return {
12417
- content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available for trend analysis" }) }]
12418
- };
12419
- }
12420
- const currentMetrics = computeMetrics(index, stateDb2);
12421
- const trends = computeTrends(stateDb2, currentMetrics, daysBack);
12422
- result = {
12423
- mode: "trends",
12424
- trends
12425
- };
12426
- break;
12427
- }
13713
+ if (!stateDb2) {
13714
+ return {
13715
+ content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available" }) }]
13716
+ };
13717
+ }
13718
+ if (!index.notes.has(path28)) {
13719
+ return {
13720
+ content: [{ type: "text", text: JSON.stringify({
13721
+ error: `Note not found: ${path28}`,
13722
+ hint: "Use the full relative path including .md extension"
13723
+ }, null, 2) }]
13724
+ };
12428
13725
  }
13726
+ const opts = {
13727
+ limit: limit ?? 10,
13728
+ excludeLinked: exclude_linked ?? true
13729
+ };
13730
+ const useHybrid = hasEmbeddingsIndex();
13731
+ const method = useHybrid ? "hybrid" : "bm25";
13732
+ const results = useHybrid ? await findHybridSimilarNotes(stateDb2.db, vaultPath2, index, path28, opts) : findSimilarNotes(stateDb2.db, vaultPath2, index, path28, opts);
12429
13733
  return {
12430
- content: [
12431
- {
13734
+ content: [{
13735
+ type: "text",
13736
+ text: JSON.stringify({
13737
+ source: path28,
13738
+ method,
13739
+ exclude_linked: exclude_linked ?? true,
13740
+ count: results.length,
13741
+ similar: results
13742
+ }, null, 2)
13743
+ }]
13744
+ };
13745
+ }
13746
+ );
13747
+ }
13748
+
13749
+ // src/tools/read/semantic.ts
13750
+ import { z as z24 } from "zod";
13751
+ function registerSemanticTools(server2, getVaultPath, getStateDb) {
13752
+ server2.registerTool(
13753
+ "init_semantic",
13754
+ {
13755
+ title: "Initialize Semantic Search",
13756
+ description: "Download the embedding model and build semantic search index for this vault. After running, search and find_similar automatically use hybrid ranking (BM25 + semantic). Run once per vault \u2014 subsequent calls skip already-embedded notes unless force=true.",
13757
+ inputSchema: {
13758
+ force: z24.boolean().optional().describe(
13759
+ "Rebuild all embeddings even if they already exist (default: false)"
13760
+ )
13761
+ }
13762
+ },
13763
+ async ({ force }) => {
13764
+ const stateDb2 = getStateDb();
13765
+ if (!stateDb2) {
13766
+ return {
13767
+ content: [{
12432
13768
  type: "text",
12433
- text: JSON.stringify(result, null, 2)
12434
- }
12435
- ]
13769
+ text: JSON.stringify({ error: "StateDb not available \u2014 vault may not be initialized yet" })
13770
+ }]
13771
+ };
13772
+ }
13773
+ setEmbeddingsDatabase(stateDb2.db);
13774
+ if (hasEmbeddingsIndex() && !force) {
13775
+ const count = getEmbeddingsCount();
13776
+ return {
13777
+ content: [{
13778
+ type: "text",
13779
+ text: JSON.stringify({
13780
+ success: true,
13781
+ already_built: true,
13782
+ embedded: count,
13783
+ hint: "Embeddings already built. All searches automatically use hybrid ranking."
13784
+ }, null, 2)
13785
+ }]
13786
+ };
13787
+ }
13788
+ const vaultPath2 = getVaultPath();
13789
+ const progress = await buildEmbeddingsIndex(vaultPath2, (p) => {
13790
+ if (p.current % 50 === 0 || p.current === p.total) {
13791
+ console.error(`[Semantic] Embedding ${p.current}/${p.total} notes (${p.skipped} skipped)...`);
13792
+ }
13793
+ });
13794
+ const embedded = progress.total - progress.skipped;
13795
+ return {
13796
+ content: [{
13797
+ type: "text",
13798
+ text: JSON.stringify({
13799
+ success: true,
13800
+ embedded,
13801
+ skipped: progress.skipped,
13802
+ total: progress.total,
13803
+ hint: "Embeddings built. All searches now automatically use hybrid ranking."
13804
+ }, null, 2)
13805
+ }]
12436
13806
  };
12437
13807
  }
12438
13808
  );
@@ -12631,6 +14001,7 @@ var TOOL_CATEGORY = {
12631
14001
  get_unlinked_mentions: "health",
12632
14002
  // search (unified: metadata + content + entities)
12633
14003
  search: "search",
14004
+ init_semantic: "search",
12634
14005
  // backlinks
12635
14006
  get_backlinks: "backlinks",
12636
14007
  get_forward_links: "backlinks",
@@ -12677,7 +14048,11 @@ var TOOL_CATEGORY = {
12677
14048
  // health (growth metrics)
12678
14049
  vault_growth: "health",
12679
14050
  // wikilinks (feedback)
12680
- wikilink_feedback: "wikilinks"
14051
+ wikilink_feedback: "wikilinks",
14052
+ // health (activity tracking)
14053
+ vault_activity: "health",
14054
+ // schema (content similarity)
14055
+ find_similar: "schema"
12681
14056
  };
12682
14057
  var server = new McpServer({
12683
14058
  name: "flywheel-memory",
@@ -12694,21 +14069,68 @@ function gateByCategory(name) {
12694
14069
  _registeredCount++;
12695
14070
  return true;
12696
14071
  }
14072
+ function wrapHandlerWithTracking(toolName, handler) {
14073
+ return async (...args) => {
14074
+ const start = Date.now();
14075
+ let success = true;
14076
+ let notePaths;
14077
+ const params = args[0];
14078
+ if (params && typeof params === "object") {
14079
+ const paths = [];
14080
+ if (typeof params.path === "string") paths.push(params.path);
14081
+ if (Array.isArray(params.paths)) paths.push(...params.paths.filter((p) => typeof p === "string"));
14082
+ if (typeof params.note_path === "string") paths.push(params.note_path);
14083
+ if (typeof params.source === "string") paths.push(params.source);
14084
+ if (typeof params.target === "string") paths.push(params.target);
14085
+ if (paths.length > 0) notePaths = paths;
14086
+ }
14087
+ try {
14088
+ return await handler(...args);
14089
+ } catch (err) {
14090
+ success = false;
14091
+ throw err;
14092
+ } finally {
14093
+ if (stateDb) {
14094
+ try {
14095
+ let sessionId;
14096
+ try {
14097
+ sessionId = getSessionId();
14098
+ } catch {
14099
+ }
14100
+ recordToolInvocation(stateDb, {
14101
+ tool_name: toolName,
14102
+ session_id: sessionId,
14103
+ note_paths: notePaths,
14104
+ duration_ms: Date.now() - start,
14105
+ success
14106
+ });
14107
+ } catch {
14108
+ }
14109
+ }
14110
+ }
14111
+ };
14112
+ }
12697
14113
  var _originalTool = server.tool.bind(server);
12698
14114
  server.tool = (name, ...args) => {
12699
14115
  if (!gateByCategory(name)) return;
14116
+ if (args.length > 0 && typeof args[args.length - 1] === "function") {
14117
+ args[args.length - 1] = wrapHandlerWithTracking(name, args[args.length - 1]);
14118
+ }
12700
14119
  return _originalTool(name, ...args);
12701
14120
  };
12702
14121
  var _originalRegisterTool = server.registerTool?.bind(server);
12703
14122
  if (_originalRegisterTool) {
12704
14123
  server.registerTool = (name, ...args) => {
12705
14124
  if (!gateByCategory(name)) return;
14125
+ if (args.length > 0 && typeof args[args.length - 1] === "function") {
14126
+ args[args.length - 1] = wrapHandlerWithTracking(name, args[args.length - 1]);
14127
+ }
12706
14128
  return _originalRegisterTool(name, ...args);
12707
14129
  };
12708
14130
  }
12709
14131
  var categoryList = Array.from(enabledCategories).sort().join(", ");
12710
14132
  console.error(`[Memory] Tool categories: ${categoryList}`);
12711
- registerHealthTools(server, () => vaultIndex, () => vaultPath, () => flywheelConfig);
14133
+ registerHealthTools(server, () => vaultIndex, () => vaultPath, () => flywheelConfig, () => stateDb);
12712
14134
  registerSystemTools(
12713
14135
  server,
12714
14136
  () => vaultIndex,
@@ -12725,7 +14147,7 @@ registerGraphTools(server, () => vaultIndex, () => vaultPath);
12725
14147
  registerWikilinkTools(server, () => vaultIndex, () => vaultPath);
12726
14148
  registerQueryTools(server, () => vaultIndex, () => vaultPath, () => stateDb);
12727
14149
  registerPrimitiveTools(server, () => vaultIndex, () => vaultPath, () => flywheelConfig);
12728
- registerGraphAnalysisTools(server, () => vaultIndex, () => vaultPath);
14150
+ registerGraphAnalysisTools(server, () => vaultIndex, () => vaultPath, () => stateDb);
12729
14151
  registerVaultSchemaTools(server, () => vaultIndex, () => vaultPath);
12730
14152
  registerNoteIntelligenceTools(server, () => vaultIndex, () => vaultPath);
12731
14153
  registerMigrationTools(server, () => vaultIndex, () => vaultPath);
@@ -12739,6 +14161,15 @@ registerPolicyTools(server, vaultPath);
12739
14161
  registerTagTools(server, () => vaultIndex, () => vaultPath);
12740
14162
  registerWikilinkFeedbackTools(server, () => stateDb);
12741
14163
  registerMetricsTools(server, () => vaultIndex, () => stateDb);
14164
+ registerActivityTools(server, () => stateDb, () => {
14165
+ try {
14166
+ return getSessionId();
14167
+ } catch {
14168
+ return null;
14169
+ }
14170
+ });
14171
+ registerSimilarityTools(server, () => vaultIndex, () => vaultPath, () => stateDb);
14172
+ registerSemanticTools(server, () => vaultPath, () => stateDb);
12742
14173
  registerVaultResources(server, () => vaultIndex ?? null);
12743
14174
  console.error(`[Memory] Registered ${_registeredCount} tools, skipped ${_skippedCount}`);
12744
14175
  async function main() {
@@ -12749,6 +14180,7 @@ async function main() {
12749
14180
  stateDb = openStateDb(vaultPath);
12750
14181
  console.error("[Memory] StateDb initialized");
12751
14182
  setFTS5Database(stateDb.db);
14183
+ setEmbeddingsDatabase(stateDb.db);
12752
14184
  setWriteStateDb(stateDb);
12753
14185
  await initializeEntityIndex(vaultPath);
12754
14186
  } catch (err) {
@@ -12785,6 +14217,13 @@ async function main() {
12785
14217
  setIndexState("ready");
12786
14218
  const duration = Date.now() - startTime;
12787
14219
  console.error(`[Memory] Index loaded from cache in ${duration}ms`);
14220
+ if (stateDb) {
14221
+ recordIndexEvent(stateDb, {
14222
+ trigger: "startup_cache",
14223
+ duration_ms: duration,
14224
+ note_count: cachedIndex.notes.size
14225
+ });
14226
+ }
12788
14227
  runPostIndexWork(vaultIndex);
12789
14228
  } else {
12790
14229
  console.error("[Memory] Building vault index...");
@@ -12793,6 +14232,13 @@ async function main() {
12793
14232
  setIndexState("ready");
12794
14233
  const duration = Date.now() - startTime;
12795
14234
  console.error(`[Memory] Vault index ready in ${duration}ms`);
14235
+ if (stateDb) {
14236
+ recordIndexEvent(stateDb, {
14237
+ trigger: "startup_build",
14238
+ duration_ms: duration,
14239
+ note_count: vaultIndex.notes.size
14240
+ });
14241
+ }
12796
14242
  if (stateDb) {
12797
14243
  try {
12798
14244
  saveVaultIndexToCache(stateDb, vaultIndex);
@@ -12805,6 +14251,15 @@ async function main() {
12805
14251
  } catch (err) {
12806
14252
  setIndexState("error");
12807
14253
  setIndexError(err instanceof Error ? err : new Error(String(err)));
14254
+ const duration = Date.now() - startTime;
14255
+ if (stateDb) {
14256
+ recordIndexEvent(stateDb, {
14257
+ trigger: "startup_build",
14258
+ duration_ms: duration,
14259
+ success: false,
14260
+ error: err instanceof Error ? err.message : String(err)
14261
+ });
14262
+ }
12808
14263
  console.error("[Memory] Failed to build vault index:", err);
12809
14264
  }
12810
14265
  }
@@ -12849,11 +14304,22 @@ async function runPostIndexWork(index) {
12849
14304
  const metrics = computeMetrics(index, stateDb);
12850
14305
  recordMetrics(stateDb, metrics);
12851
14306
  purgeOldMetrics(stateDb, 90);
14307
+ purgeOldIndexEvents(stateDb, 90);
14308
+ purgeOldInvocations(stateDb, 90);
12852
14309
  console.error("[Memory] Growth metrics recorded");
12853
14310
  } catch (err) {
12854
14311
  console.error("[Memory] Failed to record metrics:", err);
12855
14312
  }
12856
14313
  }
14314
+ if (stateDb) {
14315
+ try {
14316
+ const graphMetrics = computeGraphMetrics(index);
14317
+ recordGraphSnapshot(stateDb, graphMetrics);
14318
+ purgeOldSnapshots(stateDb, 90);
14319
+ } catch (err) {
14320
+ console.error("[Memory] Failed to record graph snapshot:", err);
14321
+ }
14322
+ }
12857
14323
  if (stateDb) {
12858
14324
  try {
12859
14325
  updateSuppressionList(stateDb);
@@ -12878,13 +14344,37 @@ async function runPostIndexWork(index) {
12878
14344
  config,
12879
14345
  onBatch: async (batch) => {
12880
14346
  console.error(`[Memory] Processing ${batch.events.length} file changes`);
12881
- const startTime = Date.now();
14347
+ const batchStart = Date.now();
14348
+ const changedPaths = batch.events.map((e) => e.path);
12882
14349
  try {
12883
14350
  vaultIndex = await buildVaultIndex(vaultPath);
12884
14351
  setIndexState("ready");
12885
- console.error(`[Memory] Index rebuilt in ${Date.now() - startTime}ms`);
14352
+ const duration = Date.now() - batchStart;
14353
+ console.error(`[Memory] Index rebuilt in ${duration}ms`);
14354
+ if (stateDb) {
14355
+ recordIndexEvent(stateDb, {
14356
+ trigger: "watcher",
14357
+ duration_ms: duration,
14358
+ note_count: vaultIndex.notes.size,
14359
+ files_changed: batch.events.length,
14360
+ changed_paths: changedPaths
14361
+ });
14362
+ }
12886
14363
  await updateEntitiesInStateDb();
12887
14364
  await exportHubScores(vaultIndex, stateDb);
14365
+ if (hasEmbeddingsIndex()) {
14366
+ for (const event of batch.events) {
14367
+ try {
14368
+ if (event.type === "delete") {
14369
+ removeEmbedding(event.path);
14370
+ } else if (event.path.endsWith(".md")) {
14371
+ const absPath = path27.join(vaultPath, event.path);
14372
+ await updateEmbedding(event.path, absPath);
14373
+ }
14374
+ } catch {
14375
+ }
14376
+ }
14377
+ }
12888
14378
  if (stateDb) {
12889
14379
  try {
12890
14380
  saveVaultIndexToCache(stateDb, vaultIndex);
@@ -12895,6 +14385,17 @@ async function runPostIndexWork(index) {
12895
14385
  } catch (err) {
12896
14386
  setIndexState("error");
12897
14387
  setIndexError(err instanceof Error ? err : new Error(String(err)));
14388
+ const duration = Date.now() - batchStart;
14389
+ if (stateDb) {
14390
+ recordIndexEvent(stateDb, {
14391
+ trigger: "watcher",
14392
+ duration_ms: duration,
14393
+ success: false,
14394
+ files_changed: batch.events.length,
14395
+ changed_paths: changedPaths,
14396
+ error: err instanceof Error ? err.message : String(err)
14397
+ });
14398
+ }
12898
14399
  console.error("[Memory] Failed to rebuild index:", err);
12899
14400
  }
12900
14401
  },
@@ -12910,10 +14411,32 @@ async function runPostIndexWork(index) {
12910
14411
  watcher.start();
12911
14412
  }
12912
14413
  }
12913
- main().catch((error) => {
12914
- console.error("[Memory] Fatal error:", error);
12915
- process.exit(1);
12916
- });
14414
+ if (process.argv.includes("--init-semantic")) {
14415
+ (async () => {
14416
+ console.error("[Semantic] Pre-warming semantic search...");
14417
+ console.error(`[Semantic] Vault: ${vaultPath}`);
14418
+ try {
14419
+ const db3 = openStateDb(vaultPath);
14420
+ setEmbeddingsDatabase(db3.db);
14421
+ const progress = await buildEmbeddingsIndex(vaultPath, (p) => {
14422
+ if (p.current % 50 === 0 || p.current === p.total) {
14423
+ console.error(`[Semantic] Embedding ${p.current}/${p.total} notes (${p.skipped} skipped)...`);
14424
+ }
14425
+ });
14426
+ console.error(`[Semantic] Done. Embedded ${progress.total - progress.skipped} notes, skipped ${progress.skipped}.`);
14427
+ db3.close();
14428
+ process.exit(0);
14429
+ } catch (err) {
14430
+ console.error("[Semantic] Failed:", err instanceof Error ? err.message : err);
14431
+ process.exit(1);
14432
+ }
14433
+ })();
14434
+ } else {
14435
+ main().catch((error) => {
14436
+ console.error("[Memory] Fatal error:", error);
14437
+ process.exit(1);
14438
+ });
14439
+ }
12917
14440
  process.on("beforeExit", async () => {
12918
14441
  await flushLogs();
12919
14442
  });