@velvetmonkey/flywheel-memory 2.0.18 → 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 +2338 -993
  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
@@ -6316,7 +6534,7 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
6316
6534
  const indexErrorObj = getIndexError();
6317
6535
  let vaultAccessible = false;
6318
6536
  try {
6319
- fs8.accessSync(vaultPath2, fs8.constants.R_OK);
6537
+ fs9.accessSync(vaultPath2, fs9.constants.R_OK);
6320
6538
  vaultAccessible = true;
6321
6539
  } catch {
6322
6540
  vaultAccessible = false;
@@ -6447,8 +6665,8 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
6447
6665
  daily_counts: z3.record(z3.number())
6448
6666
  }).describe("Activity summary for the last 7 days")
6449
6667
  };
6450
- function isPeriodicNote(path25) {
6451
- const filename = path25.split("/").pop() || "";
6668
+ function isPeriodicNote(path28) {
6669
+ const filename = path28.split("/").pop() || "";
6452
6670
  const nameWithoutExt = filename.replace(/\.md$/, "");
6453
6671
  const patterns = [
6454
6672
  /^\d{4}-\d{2}-\d{2}$/,
@@ -6463,7 +6681,7 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
6463
6681
  // YYYY (yearly)
6464
6682
  ];
6465
6683
  const periodicFolders = ["daily", "weekly", "monthly", "quarterly", "yearly", "journal", "journals"];
6466
- const folder = path25.split("/")[0]?.toLowerCase() || "";
6684
+ const folder = path28.split("/")[0]?.toLowerCase() || "";
6467
6685
  return patterns.some((p) => p.test(nameWithoutExt)) || periodicFolders.includes(folder);
6468
6686
  }
6469
6687
  server2.registerTool(
@@ -6632,10 +6850,10 @@ function sortNotes(notes, sortBy, order) {
6632
6850
  function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
6633
6851
  server2.tool(
6634
6852
  "search",
6635
- '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" })',
6636
6854
  {
6637
6855
  query: z4.string().optional().describe('Search query text. Required for scope "content", "entities", "all". For "metadata" scope, use filters instead.'),
6638
- 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)."),
6639
6857
  // Metadata filters (used with scope "metadata" or "all")
6640
6858
  where: z4.record(z4.unknown()).optional().describe('Frontmatter filters as key-value pairs. Example: { "type": "project", "status": "active" }'),
6641
6859
  has_tag: z4.string().optional().describe("Filter to notes with this tag"),
@@ -6744,12 +6962,42 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
6744
6962
  console.error("[FTS5] Index stale or missing, rebuilding...");
6745
6963
  await buildFTS5Index(vaultPath2);
6746
6964
  }
6747
- 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
+ }
6748
6995
  return { content: [{ type: "text", text: JSON.stringify({
6749
6996
  scope: "content",
6997
+ method: "fts5",
6750
6998
  query,
6751
- total_results: results.length,
6752
- results
6999
+ total_results: fts5Results.length,
7000
+ results: fts5Results
6753
7001
  }, null, 2) }] };
6754
7002
  }
6755
7003
  return { content: [{ type: "text", text: JSON.stringify({ error: "Invalid scope" }, null, 2) }] };
@@ -6758,7 +7006,7 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
6758
7006
  }
6759
7007
 
6760
7008
  // src/tools/read/system.ts
6761
- import * as fs9 from "fs";
7009
+ import * as fs10 from "fs";
6762
7010
  import * as path9 from "path";
6763
7011
  import { z as z5 } from "zod";
6764
7012
  import { scanVaultEntities as scanVaultEntities2 } from "@velvetmonkey/vault-core";
@@ -6993,7 +7241,7 @@ function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfi
6993
7241
  }
6994
7242
  try {
6995
7243
  const fullPath = path9.join(vaultPath2, note.path);
6996
- const content = await fs9.promises.readFile(fullPath, "utf-8");
7244
+ const content = await fs10.promises.readFile(fullPath, "utf-8");
6997
7245
  const lines = content.split("\n");
6998
7246
  for (let i = 0; i < lines.length; i++) {
6999
7247
  const line = lines[i];
@@ -7109,7 +7357,7 @@ function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfi
7109
7357
  if (include_word_count) {
7110
7358
  try {
7111
7359
  const fullPath = path9.join(vaultPath2, resolvedPath);
7112
- const content = await fs9.promises.readFile(fullPath, "utf-8");
7360
+ const content = await fs10.promises.readFile(fullPath, "utf-8");
7113
7361
  wordCount = content.split(/\s+/).filter((w) => w.length > 0).length;
7114
7362
  } catch {
7115
7363
  }
@@ -7211,7 +7459,7 @@ function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfi
7211
7459
  import { z as z6 } from "zod";
7212
7460
 
7213
7461
  // src/tools/read/structure.ts
7214
- import * as fs10 from "fs";
7462
+ import * as fs11 from "fs";
7215
7463
  import * as path10 from "path";
7216
7464
  var HEADING_REGEX = /^(#{1,6})\s+(.+)$/;
7217
7465
  function extractHeadings(content) {
@@ -7269,7 +7517,7 @@ async function getNoteStructure(index, notePath, vaultPath2) {
7269
7517
  const absolutePath = path10.join(vaultPath2, notePath);
7270
7518
  let content;
7271
7519
  try {
7272
- content = await fs10.promises.readFile(absolutePath, "utf-8");
7520
+ content = await fs11.promises.readFile(absolutePath, "utf-8");
7273
7521
  } catch {
7274
7522
  return null;
7275
7523
  }
@@ -7292,7 +7540,7 @@ async function getSectionContent(index, notePath, headingText, vaultPath2, inclu
7292
7540
  const absolutePath = path10.join(vaultPath2, notePath);
7293
7541
  let content;
7294
7542
  try {
7295
- content = await fs10.promises.readFile(absolutePath, "utf-8");
7543
+ content = await fs11.promises.readFile(absolutePath, "utf-8");
7296
7544
  } catch {
7297
7545
  return null;
7298
7546
  }
@@ -7334,7 +7582,7 @@ async function findSections(index, headingPattern, vaultPath2, folder) {
7334
7582
  const absolutePath = path10.join(vaultPath2, note.path);
7335
7583
  let content;
7336
7584
  try {
7337
- content = await fs10.promises.readFile(absolutePath, "utf-8");
7585
+ content = await fs11.promises.readFile(absolutePath, "utf-8");
7338
7586
  } catch {
7339
7587
  continue;
7340
7588
  }
@@ -7354,7 +7602,7 @@ async function findSections(index, headingPattern, vaultPath2, folder) {
7354
7602
  }
7355
7603
 
7356
7604
  // src/tools/read/tasks.ts
7357
- import * as fs11 from "fs";
7605
+ import * as fs12 from "fs";
7358
7606
  import * as path11 from "path";
7359
7607
  var TASK_REGEX = /^(\s*)- \[([ xX\-])\]\s+(.+)$/;
7360
7608
  var TAG_REGEX2 = /#([a-zA-Z][a-zA-Z0-9_/-]*)/g;
@@ -7381,7 +7629,7 @@ function extractDueDate(text) {
7381
7629
  async function extractTasksFromNote(notePath, absolutePath) {
7382
7630
  let content;
7383
7631
  try {
7384
- content = await fs11.promises.readFile(absolutePath, "utf-8");
7632
+ content = await fs12.promises.readFile(absolutePath, "utf-8");
7385
7633
  } catch {
7386
7634
  return [];
7387
7635
  }
@@ -7486,18 +7734,18 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
7486
7734
  include_content: z6.boolean().default(false).describe("Include the text content under each top-level section")
7487
7735
  }
7488
7736
  },
7489
- async ({ path: path25, include_content }) => {
7737
+ async ({ path: path28, include_content }) => {
7490
7738
  const index = getIndex();
7491
7739
  const vaultPath2 = getVaultPath();
7492
- const result = await getNoteStructure(index, path25, vaultPath2);
7740
+ const result = await getNoteStructure(index, path28, vaultPath2);
7493
7741
  if (!result) {
7494
7742
  return {
7495
- 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) }]
7496
7744
  };
7497
7745
  }
7498
7746
  if (include_content) {
7499
7747
  for (const section of result.sections) {
7500
- const sectionResult = await getSectionContent(index, path25, section.heading.text, vaultPath2, true);
7748
+ const sectionResult = await getSectionContent(index, path28, section.heading.text, vaultPath2, true);
7501
7749
  if (sectionResult) {
7502
7750
  section.content = sectionResult.content;
7503
7751
  }
@@ -7519,15 +7767,15 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
7519
7767
  include_subheadings: z6.boolean().default(true).describe("Include content under subheadings")
7520
7768
  }
7521
7769
  },
7522
- async ({ path: path25, heading, include_subheadings }) => {
7770
+ async ({ path: path28, heading, include_subheadings }) => {
7523
7771
  const index = getIndex();
7524
7772
  const vaultPath2 = getVaultPath();
7525
- const result = await getSectionContent(index, path25, heading, vaultPath2, include_subheadings);
7773
+ const result = await getSectionContent(index, path28, heading, vaultPath2, include_subheadings);
7526
7774
  if (!result) {
7527
7775
  return {
7528
7776
  content: [{ type: "text", text: JSON.stringify({
7529
7777
  error: "Section not found",
7530
- path: path25,
7778
+ path: path28,
7531
7779
  heading
7532
7780
  }, null, 2) }]
7533
7781
  };
@@ -7581,16 +7829,16 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
7581
7829
  offset: z6.coerce.number().default(0).describe("Number of results to skip (for pagination)")
7582
7830
  }
7583
7831
  },
7584
- 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 }) => {
7585
7833
  const limit = Math.min(requestedLimit ?? 25, MAX_LIMIT);
7586
7834
  const index = getIndex();
7587
7835
  const vaultPath2 = getVaultPath();
7588
7836
  const config = getConfig();
7589
- if (path25) {
7590
- 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 || []);
7591
7839
  if (!result2) {
7592
7840
  return {
7593
- 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) }]
7594
7842
  };
7595
7843
  }
7596
7844
  let filtered = result2;
@@ -7600,7 +7848,7 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
7600
7848
  const paged2 = filtered.slice(offset, offset + limit);
7601
7849
  return {
7602
7850
  content: [{ type: "text", text: JSON.stringify({
7603
- path: path25,
7851
+ path: path28,
7604
7852
  total_count: filtered.length,
7605
7853
  returned_count: paged2.length,
7606
7854
  open: result2.filter((t) => t.status === "open").length,
@@ -7715,7 +7963,7 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
7715
7963
 
7716
7964
  // src/tools/read/migrations.ts
7717
7965
  import { z as z7 } from "zod";
7718
- import * as fs12 from "fs/promises";
7966
+ import * as fs13 from "fs/promises";
7719
7967
  import * as path12 from "path";
7720
7968
  import matter2 from "gray-matter";
7721
7969
  function getNotesInFolder(index, folder) {
@@ -7731,7 +7979,7 @@ function getNotesInFolder(index, folder) {
7731
7979
  async function readFileContent(notePath, vaultPath2) {
7732
7980
  const fullPath = path12.join(vaultPath2, notePath);
7733
7981
  try {
7734
- return await fs12.readFile(fullPath, "utf-8");
7982
+ return await fs13.readFile(fullPath, "utf-8");
7735
7983
  } catch {
7736
7984
  return null;
7737
7985
  }
@@ -7739,7 +7987,7 @@ async function readFileContent(notePath, vaultPath2) {
7739
7987
  async function writeFileContent(notePath, vaultPath2, content) {
7740
7988
  const fullPath = path12.join(vaultPath2, notePath);
7741
7989
  try {
7742
- await fs12.writeFile(fullPath, content, "utf-8");
7990
+ await fs13.writeFile(fullPath, content, "utf-8");
7743
7991
  return true;
7744
7992
  } catch {
7745
7993
  return false;
@@ -7917,324 +8165,33 @@ function registerMigrationTools(server2, getIndex, getVaultPath) {
7917
8165
  }
7918
8166
 
7919
8167
  // src/tools/read/graphAnalysis.ts
8168
+ import fs14 from "node:fs";
8169
+ import path13 from "node:path";
7920
8170
  import { z as z8 } from "zod";
7921
- function registerGraphAnalysisTools(server2, getIndex, getVaultPath) {
7922
- server2.registerTool(
7923
- "graph_analysis",
7924
- {
7925
- title: "Graph Analysis",
7926
- 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 })',
7927
- inputSchema: {
7928
- analysis: z8.enum(["orphans", "dead_ends", "sources", "hubs", "stale"]).describe("Type of graph analysis to perform"),
7929
- folder: z8.string().optional().describe("Limit to notes in this folder (orphans, dead_ends, sources)"),
7930
- min_links: z8.coerce.number().default(5).describe("Minimum total connections for hubs"),
7931
- min_backlinks: z8.coerce.number().default(1).describe("Minimum backlinks (dead_ends, stale)"),
7932
- min_outlinks: z8.coerce.number().default(1).describe("Minimum outlinks (sources)"),
7933
- days: z8.coerce.number().optional().describe("Notes not modified in this many days (stale, required)"),
7934
- limit: z8.coerce.number().default(50).describe("Maximum number of results to return"),
7935
- offset: z8.coerce.number().default(0).describe("Number of results to skip (for pagination)")
7936
- }
7937
- },
7938
- async ({ analysis, folder, min_links, min_backlinks, min_outlinks, days, limit: requestedLimit, offset }) => {
7939
- requireIndex();
7940
- const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
7941
- const index = getIndex();
7942
- switch (analysis) {
7943
- case "orphans": {
7944
- const allOrphans = findOrphanNotes(index, folder);
7945
- const orphans = allOrphans.slice(offset, offset + limit);
7946
- return {
7947
- content: [{ type: "text", text: JSON.stringify({
7948
- analysis: "orphans",
7949
- orphan_count: allOrphans.length,
7950
- returned_count: orphans.length,
7951
- folder,
7952
- orphans: orphans.map((o) => ({
7953
- path: o.path,
7954
- title: o.title,
7955
- modified: o.modified.toISOString()
7956
- }))
7957
- }, null, 2) }]
7958
- };
7959
- }
7960
- case "dead_ends": {
7961
- const allResults = findDeadEnds(index, folder, min_backlinks);
7962
- const result = allResults.slice(offset, offset + limit);
7963
- return {
7964
- content: [{ type: "text", text: JSON.stringify({
7965
- analysis: "dead_ends",
7966
- criteria: { folder, min_backlinks },
7967
- total_count: allResults.length,
7968
- returned_count: result.length,
7969
- dead_ends: result
7970
- }, null, 2) }]
7971
- };
7972
- }
7973
- case "sources": {
7974
- const allResults = findSources(index, folder, min_outlinks);
7975
- const result = allResults.slice(offset, offset + limit);
7976
- return {
7977
- content: [{ type: "text", text: JSON.stringify({
7978
- analysis: "sources",
7979
- criteria: { folder, min_outlinks },
7980
- total_count: allResults.length,
7981
- returned_count: result.length,
7982
- sources: result
7983
- }, null, 2) }]
7984
- };
7985
- }
7986
- case "hubs": {
7987
- const allHubs = findHubNotes(index, min_links);
7988
- const hubs = allHubs.slice(offset, offset + limit);
7989
- return {
7990
- content: [{ type: "text", text: JSON.stringify({
7991
- analysis: "hubs",
7992
- hub_count: allHubs.length,
7993
- returned_count: hubs.length,
7994
- min_links,
7995
- hubs: hubs.map((h) => ({
7996
- path: h.path,
7997
- title: h.title,
7998
- backlink_count: h.backlink_count,
7999
- forward_link_count: h.forward_link_count,
8000
- total_connections: h.total_connections
8001
- }))
8002
- }, null, 2) }]
8003
- };
8004
- }
8005
- case "stale": {
8006
- if (days === void 0) {
8007
- return {
8008
- content: [{ type: "text", text: JSON.stringify({
8009
- error: "days parameter is required for stale analysis"
8010
- }, null, 2) }]
8011
- };
8012
- }
8013
- const result = getStaleNotes(index, days, min_backlinks).slice(0, limit);
8014
- return {
8015
- content: [{ type: "text", text: JSON.stringify({
8016
- analysis: "stale",
8017
- criteria: { days, min_backlinks },
8018
- count: result.length,
8019
- notes: result.map((n) => ({
8020
- ...n,
8021
- modified: n.modified.toISOString()
8022
- }))
8023
- }, null, 2) }]
8024
- };
8025
- }
8026
- }
8027
- }
8028
- );
8029
- }
8030
-
8031
- // src/tools/read/vaultSchema.ts
8032
- import { z as z9 } from "zod";
8033
8171
 
8034
- // src/tools/read/frontmatter.ts
8172
+ // src/tools/read/schema.ts
8035
8173
  function getValueType(value) {
8036
8174
  if (value === null) return "null";
8037
8175
  if (value === void 0) return "undefined";
8038
- if (Array.isArray(value)) return "array";
8176
+ if (Array.isArray(value)) {
8177
+ if (value.some((v) => typeof v === "string" && /^\[\[.+\]\]$/.test(v))) {
8178
+ return "wikilink[]";
8179
+ }
8180
+ return "array";
8181
+ }
8182
+ if (typeof value === "string") {
8183
+ if (/^\[\[.+\]\]$/.test(value)) return "wikilink";
8184
+ if (/^\d{4}-\d{2}-\d{2}/.test(value)) return "date";
8185
+ }
8039
8186
  if (value instanceof Date) return "date";
8040
8187
  return typeof value;
8041
8188
  }
8042
- function getFrontmatterSchema(index) {
8043
- const fieldMap = /* @__PURE__ */ new Map();
8044
- let notesWithFrontmatter = 0;
8045
- for (const note of index.notes.values()) {
8046
- const fm = note.frontmatter;
8047
- if (!fm || Object.keys(fm).length === 0) continue;
8048
- notesWithFrontmatter++;
8049
- for (const [key, value] of Object.entries(fm)) {
8050
- if (!fieldMap.has(key)) {
8051
- fieldMap.set(key, {
8052
- types: /* @__PURE__ */ new Set(),
8053
- count: 0,
8054
- examples: [],
8055
- notes: []
8056
- });
8057
- }
8058
- const info = fieldMap.get(key);
8059
- info.count++;
8060
- info.types.add(getValueType(value));
8061
- if (info.examples.length < 5) {
8062
- const valueStr = JSON.stringify(value);
8063
- const existingStrs = info.examples.map((e) => JSON.stringify(e));
8064
- if (!existingStrs.includes(valueStr)) {
8065
- info.examples.push(value);
8066
- }
8067
- }
8068
- if (info.notes.length < 5) {
8069
- info.notes.push(note.path);
8070
- }
8071
- }
8072
- }
8073
- const fields = Array.from(fieldMap.entries()).map(([name, info]) => ({
8074
- name,
8075
- types: Array.from(info.types),
8076
- count: info.count,
8077
- examples: info.examples,
8078
- notes_sample: info.notes
8079
- })).sort((a, b) => b.count - a.count);
8080
- return {
8081
- total_notes: index.notes.size,
8082
- notes_with_frontmatter: notesWithFrontmatter,
8083
- field_count: fields.length,
8084
- fields
8085
- };
8086
- }
8087
- function getFieldValues(index, fieldName) {
8088
- const valueMap = /* @__PURE__ */ new Map();
8089
- let totalWithField = 0;
8090
- for (const note of index.notes.values()) {
8091
- const value = note.frontmatter[fieldName];
8092
- if (value === void 0) continue;
8093
- totalWithField++;
8094
- const values = Array.isArray(value) ? value : [value];
8095
- for (const v of values) {
8096
- const key = JSON.stringify(v);
8097
- if (!valueMap.has(key)) {
8098
- valueMap.set(key, {
8099
- value: v,
8100
- count: 0,
8101
- notes: []
8102
- });
8103
- }
8104
- const info = valueMap.get(key);
8105
- info.count++;
8106
- info.notes.push(note.path);
8107
- }
8108
- }
8109
- const valuesList = Array.from(valueMap.values()).sort((a, b) => b.count - a.count);
8110
- return {
8111
- field: fieldName,
8112
- total_notes_with_field: totalWithField,
8113
- unique_values: valuesList.length,
8114
- values: valuesList
8115
- };
8116
- }
8117
- function findFrontmatterInconsistencies(index) {
8118
- const schema = getFrontmatterSchema(index);
8119
- const inconsistencies = [];
8120
- for (const field of schema.fields) {
8121
- if (field.types.length > 1) {
8122
- const examples = [];
8123
- for (const note of index.notes.values()) {
8124
- const value = note.frontmatter[field.name];
8125
- if (value === void 0) continue;
8126
- const type = getValueType(value);
8127
- if (!examples.some((e) => e.type === type)) {
8128
- examples.push({
8129
- type,
8130
- value,
8131
- note: note.path
8132
- });
8133
- }
8134
- if (examples.length >= field.types.length) break;
8135
- }
8136
- inconsistencies.push({
8137
- field: field.name,
8138
- types_found: field.types,
8139
- examples
8140
- });
8141
- }
8142
- }
8143
- return inconsistencies;
8144
- }
8145
- function validateFrontmatter(index, schema, folder) {
8146
- const results = [];
8147
- for (const note of index.notes.values()) {
8148
- if (folder && !note.path.startsWith(folder)) continue;
8149
- const issues = [];
8150
- for (const [fieldName, fieldSchema] of Object.entries(schema)) {
8151
- const value = note.frontmatter[fieldName];
8152
- if (fieldSchema.required && value === void 0) {
8153
- issues.push({
8154
- field: fieldName,
8155
- issue: "missing",
8156
- expected: "value required"
8157
- });
8158
- continue;
8159
- }
8160
- if (value === void 0) continue;
8161
- if (fieldSchema.type) {
8162
- const actualType = getValueType(value);
8163
- const allowedTypes = Array.isArray(fieldSchema.type) ? fieldSchema.type : [fieldSchema.type];
8164
- if (!allowedTypes.includes(actualType)) {
8165
- issues.push({
8166
- field: fieldName,
8167
- issue: "wrong_type",
8168
- expected: allowedTypes.join(" | "),
8169
- actual: actualType
8170
- });
8171
- }
8172
- }
8173
- if (fieldSchema.values) {
8174
- const valueStr = JSON.stringify(value);
8175
- const allowedStrs = fieldSchema.values.map((v) => JSON.stringify(v));
8176
- if (!allowedStrs.includes(valueStr)) {
8177
- issues.push({
8178
- field: fieldName,
8179
- issue: "invalid_value",
8180
- expected: fieldSchema.values.map((v) => String(v)).join(" | "),
8181
- actual: String(value)
8182
- });
8183
- }
8184
- }
8185
- }
8186
- if (issues.length > 0) {
8187
- results.push({
8188
- path: note.path,
8189
- issues
8190
- });
8191
- }
8192
- }
8193
- return results;
8194
- }
8195
- function findMissingFrontmatter(index, folderSchemas) {
8196
- const results = [];
8197
- for (const note of index.notes.values()) {
8198
- for (const [folder, requiredFields] of Object.entries(folderSchemas)) {
8199
- if (!note.path.startsWith(folder)) continue;
8200
- const missing = requiredFields.filter(
8201
- (field) => note.frontmatter[field] === void 0
8202
- );
8203
- if (missing.length > 0) {
8204
- results.push({
8205
- path: note.path,
8206
- folder,
8207
- missing_fields: missing
8208
- });
8209
- }
8210
- }
8211
- }
8212
- return results;
8213
- }
8214
-
8215
- // src/tools/read/schema.ts
8216
- function getValueType2(value) {
8217
- if (value === null) return "null";
8218
- if (value === void 0) return "undefined";
8219
- if (Array.isArray(value)) {
8220
- if (value.some((v) => typeof v === "string" && /^\[\[.+\]\]$/.test(v))) {
8221
- return "wikilink[]";
8222
- }
8223
- return "array";
8224
- }
8225
- if (typeof value === "string") {
8226
- if (/^\[\[.+\]\]$/.test(value)) return "wikilink";
8227
- if (/^\d{4}-\d{2}-\d{2}/.test(value)) return "date";
8228
- }
8229
- if (value instanceof Date) return "date";
8230
- return typeof value;
8231
- }
8232
- function getFolder(notePath) {
8233
- const lastSlash = notePath.lastIndexOf("/");
8234
- return lastSlash === -1 ? "" : notePath.substring(0, lastSlash);
8235
- }
8236
- function getNotesInFolder2(index, folder) {
8237
- const notes = [];
8189
+ function getFolder(notePath) {
8190
+ const lastSlash = notePath.lastIndexOf("/");
8191
+ return lastSlash === -1 ? "" : notePath.substring(0, lastSlash);
8192
+ }
8193
+ function getNotesInFolder2(index, folder) {
8194
+ const notes = [];
8238
8195
  for (const note of index.notes.values()) {
8239
8196
  if (!folder || note.path.startsWith(folder + "/") || getFolder(note.path) === folder) {
8240
8197
  notes.push(note);
@@ -8298,7 +8255,7 @@ function inferFolderConventions(index, folder, minConfidence = 0.5) {
8298
8255
  }
8299
8256
  const stats = fieldStats.get(key);
8300
8257
  stats.count++;
8301
- const type = getValueType2(value);
8258
+ const type = getValueType(value);
8302
8259
  stats.types.set(type, (stats.types.get(type) || 0) + 1);
8303
8260
  const valueStr = JSON.stringify(value);
8304
8261
  stats.values.set(valueStr, (stats.values.get(valueStr) || 0) + 1);
@@ -8430,7 +8387,7 @@ function suggestFieldValues(index, field, context) {
8430
8387
  const value = note.frontmatter[field];
8431
8388
  if (value === void 0) continue;
8432
8389
  totalWithField++;
8433
- const type = getValueType2(value);
8390
+ const type = getValueType(value);
8434
8391
  typeCounts.set(type, (typeCounts.get(type) || 0) + 1);
8435
8392
  const values = Array.isArray(value) ? value : [value];
8436
8393
  for (const v of values) {
@@ -8489,148 +8446,480 @@ function suggestFieldValues(index, field, context) {
8489
8446
  is_enumerable: isEnumerable
8490
8447
  };
8491
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
+ }
8492
8520
 
8493
- // src/tools/read/vaultSchema.ts
8494
- function registerVaultSchemaTools(server2, getIndex, getVaultPath) {
8495
- server2.registerTool(
8496
- "vault_schema",
8497
- {
8498
- title: "Vault Schema",
8499
- 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" })',
8500
- inputSchema: {
8501
- analysis: z9.enum([
8502
- "overview",
8503
- "field_values",
8504
- "inconsistencies",
8505
- "validate",
8506
- "missing",
8507
- "conventions",
8508
- "incomplete",
8509
- "suggest_values"
8510
- ]).describe("Type of schema analysis to perform"),
8511
- field: z9.string().optional().describe("Field name (field_values, suggest_values)"),
8512
- folder: z9.string().optional().describe("Folder to scope analysis to"),
8513
- schema: z9.record(z9.object({
8514
- required: z9.boolean().optional().describe("Whether field is required"),
8515
- type: z9.union([z9.string(), z9.array(z9.string())]).optional().describe("Expected type(s)"),
8516
- values: z9.array(z9.unknown()).optional().describe("Allowed values")
8517
- })).optional().describe("Schema to validate against (validate mode)"),
8518
- folder_schemas: z9.record(z9.array(z9.string())).optional().describe("Map of folder paths to required fields (missing mode)"),
8519
- min_confidence: z9.coerce.number().min(0).max(1).optional().describe("Minimum confidence threshold (conventions)"),
8520
- min_frequency: z9.coerce.number().min(0).max(1).optional().describe("Minimum field frequency (incomplete)"),
8521
- existing_frontmatter: z9.record(z9.unknown()).optional().describe("Existing frontmatter for context (suggest_values)"),
8522
- limit: z9.coerce.number().default(50).describe("Maximum results to return"),
8523
- 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);
8524
8545
  }
8525
- },
8526
- async (params) => {
8527
- requireIndex();
8528
- const limit = Math.min(params.limit ?? 50, MAX_LIMIT);
8529
- const index = getIndex();
8530
- switch (params.analysis) {
8531
- case "overview": {
8532
- const result = getFrontmatterSchema(index);
8533
- return {
8534
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
8535
- };
8536
- }
8537
- case "field_values": {
8538
- if (!params.field) {
8539
- return {
8540
- content: [{ type: "text", text: JSON.stringify({
8541
- error: "field parameter is required for field_values analysis"
8542
- }, null, 2) }]
8543
- };
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);
8544
8583
  }
8545
- const result = getFieldValues(index, params.field);
8546
- return {
8547
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
8548
- };
8549
8584
  }
8550
- case "inconsistencies": {
8551
- const result = findFrontmatterInconsistencies(index);
8552
- return {
8553
- content: [{ type: "text", text: JSON.stringify({
8554
- inconsistency_count: result.length,
8555
- inconsistencies: result
8556
- }, null, 2) }]
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
+ }))
8759
+ }, null, 2) }]
8557
8760
  };
8558
8761
  }
8559
- case "validate": {
8560
- if (!params.schema) {
8561
- return {
8562
- content: [{ type: "text", text: JSON.stringify({
8563
- error: "schema parameter is required for validate analysis"
8564
- }, null, 2) }]
8565
- };
8566
- }
8567
- const result = validateFrontmatter(
8568
- index,
8569
- params.schema,
8570
- params.folder
8571
- );
8762
+ case "dead_ends": {
8763
+ const allResults = findDeadEnds(index, folder, min_backlinks);
8764
+ const result = allResults.slice(offset, offset + limit);
8572
8765
  return {
8573
8766
  content: [{ type: "text", text: JSON.stringify({
8574
- notes_with_issues: result.length,
8575
- 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
8576
8772
  }, null, 2) }]
8577
8773
  };
8578
8774
  }
8579
- case "missing": {
8580
- 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) {
8581
8809
  return {
8582
8810
  content: [{ type: "text", text: JSON.stringify({
8583
- error: "folder_schemas parameter is required for missing analysis"
8811
+ error: "days parameter is required for stale analysis"
8584
8812
  }, null, 2) }]
8585
8813
  };
8586
8814
  }
8587
- const result = findMissingFrontmatter(
8588
- index,
8589
- params.folder_schemas
8590
- );
8815
+ const result = getStaleNotes(index, days, min_backlinks).slice(0, limit);
8591
8816
  return {
8592
8817
  content: [{ type: "text", text: JSON.stringify({
8593
- notes_with_missing_fields: result.length,
8594
- 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
+ }))
8595
8825
  }, null, 2) }]
8596
8826
  };
8597
8827
  }
8598
- case "conventions": {
8599
- const result = inferFolderConventions(
8600
- index,
8601
- params.folder,
8602
- 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
8603
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);
8604
8875
  return {
8605
- 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) }]
8606
8884
  };
8607
8885
  }
8608
- case "incomplete": {
8609
- const result = findIncompleteNotes(
8610
- index,
8611
- params.folder,
8612
- params.min_frequency ?? 0.7,
8613
- limit,
8614
- params.offset ?? 0
8615
- );
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);
8616
8897
  return {
8617
- 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) }]
8618
8903
  };
8619
8904
  }
8620
- case "suggest_values": {
8621
- if (!params.field) {
8905
+ case "emerging_hubs": {
8906
+ const db3 = getStateDb?.();
8907
+ if (!db3) {
8622
8908
  return {
8623
8909
  content: [{ type: "text", text: JSON.stringify({
8624
- error: "field parameter is required for suggest_values analysis"
8910
+ error: "StateDb not available \u2014 emerging hubs requires persistent state"
8625
8911
  }, null, 2) }]
8626
8912
  };
8627
8913
  }
8628
- const result = suggestFieldValues(index, params.field, {
8629
- folder: params.folder,
8630
- existing_frontmatter: params.existing_frontmatter
8631
- });
8914
+ const daysBack = days ?? 30;
8915
+ const hubs = getEmergingHubs(db3, daysBack);
8632
8916
  return {
8633
- 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) }]
8634
8923
  };
8635
8924
  }
8636
8925
  }
@@ -8638,45 +8927,392 @@ function registerVaultSchemaTools(server2, getIndex, getVaultPath) {
8638
8927
  );
8639
8928
  }
8640
8929
 
8641
- // src/tools/read/noteIntelligence.ts
8642
- import { z as z10 } from "zod";
8930
+ // src/tools/read/vaultSchema.ts
8931
+ import { z as z9 } from "zod";
8643
8932
 
8644
- // src/tools/read/bidirectional.ts
8645
- import * as fs13 from "fs/promises";
8646
- import * as path13 from "path";
8647
- import matter3 from "gray-matter";
8648
- var PROSE_PATTERN_REGEX = /^([A-Za-z][A-Za-z0-9 _-]*):\s*(?:\[\[([^\]]+)\]\]|"([^"]+)"|([^\n]+?))\s*$/gm;
8649
- var CODE_BLOCK_REGEX2 = /```[\s\S]*?```|`[^`\n]+`/g;
8650
- var WIKILINK_REGEX2 = /\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]+)?\]\]/g;
8651
- async function readFileContent2(notePath, vaultPath2) {
8652
- const fullPath = path13.join(vaultPath2, notePath);
8653
- try {
8654
- return await fs13.readFile(fullPath, "utf-8");
8655
- } catch {
8656
- return null;
8657
- }
8658
- }
8659
- function getBodyContent(content) {
8660
- try {
8661
- const parsed = matter3(content);
8662
- const frontmatterMatch = content.match(/^---\n[\s\S]*?\n---\n?/);
8663
- const bodyStartLine = frontmatterMatch ? frontmatterMatch[0].split("\n").length : 1;
8664
- return { body: parsed.content, bodyStartLine };
8665
- } catch {
8666
- return { body: content, bodyStartLine: 1 };
8667
- }
8668
- }
8669
- function removeCodeBlocks(content) {
8670
- return content.replace(CODE_BLOCK_REGEX2, (match) => {
8671
- const newlines = (match.match(/\n/g) || []).length;
8672
- return "\n".repeat(newlines);
8673
- });
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;
8674
8940
  }
8675
- function extractWikilinksFromValue(value) {
8676
- if (typeof value === "string") {
8677
- const matches = [];
8678
- let match;
8679
- 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;
8680
9316
  while ((match = WIKILINK_REGEX2.exec(value)) !== null) {
8681
9317
  matches.push(match[1].trim());
8682
9318
  }
@@ -8918,21 +9554,21 @@ async function validateCrossLayer(index, notePath, vaultPath2) {
8918
9554
  }
8919
9555
 
8920
9556
  // src/tools/read/computed.ts
8921
- import * as fs14 from "fs/promises";
8922
- import * as path14 from "path";
9557
+ import * as fs16 from "fs/promises";
9558
+ import * as path15 from "path";
8923
9559
  import matter4 from "gray-matter";
8924
9560
  async function readFileContent3(notePath, vaultPath2) {
8925
- const fullPath = path14.join(vaultPath2, notePath);
9561
+ const fullPath = path15.join(vaultPath2, notePath);
8926
9562
  try {
8927
- return await fs14.readFile(fullPath, "utf-8");
9563
+ return await fs16.readFile(fullPath, "utf-8");
8928
9564
  } catch {
8929
9565
  return null;
8930
9566
  }
8931
9567
  }
8932
9568
  async function getFileStats(notePath, vaultPath2) {
8933
- const fullPath = path14.join(vaultPath2, notePath);
9569
+ const fullPath = path15.join(vaultPath2, notePath);
8934
9570
  try {
8935
- const stats = await fs14.stat(fullPath);
9571
+ const stats = await fs16.stat(fullPath);
8936
9572
  return {
8937
9573
  modified: stats.mtime,
8938
9574
  created: stats.birthtime
@@ -9142,8 +9778,8 @@ function registerNoteIntelligenceTools(server2, getIndex, getVaultPath) {
9142
9778
  // src/tools/write/mutations.ts
9143
9779
  init_writer();
9144
9780
  import { z as z11 } from "zod";
9145
- import fs17 from "fs/promises";
9146
- import path17 from "path";
9781
+ import fs19 from "fs/promises";
9782
+ import path18 from "path";
9147
9783
 
9148
9784
  // src/core/write/validator.ts
9149
9785
  var TIMESTAMP_PATTERN = /^\*\*\d{2}:\d{2}\*\*/;
@@ -9345,8 +9981,8 @@ function runValidationPipeline(content, format, options = {}) {
9345
9981
 
9346
9982
  // src/core/write/mutation-helpers.ts
9347
9983
  init_writer();
9348
- import fs16 from "fs/promises";
9349
- import path16 from "path";
9984
+ import fs18 from "fs/promises";
9985
+ import path17 from "path";
9350
9986
  init_constants();
9351
9987
  init_writer();
9352
9988
  function formatMcpResult(result) {
@@ -9395,9 +10031,9 @@ async function handleGitCommit(vaultPath2, notePath, commit, prefix) {
9395
10031
  return info;
9396
10032
  }
9397
10033
  async function ensureFileExists(vaultPath2, notePath) {
9398
- const fullPath = path16.join(vaultPath2, notePath);
10034
+ const fullPath = path17.join(vaultPath2, notePath);
9399
10035
  try {
9400
- await fs16.access(fullPath);
10036
+ await fs18.access(fullPath);
9401
10037
  return null;
9402
10038
  } catch {
9403
10039
  return errorResult(notePath, `File not found: ${notePath}`);
@@ -9495,10 +10131,10 @@ async function withVaultFrontmatter(options, operation) {
9495
10131
 
9496
10132
  // src/tools/write/mutations.ts
9497
10133
  async function createNoteFromTemplate(vaultPath2, notePath, config) {
9498
- const fullPath = path17.join(vaultPath2, notePath);
9499
- await fs17.mkdir(path17.dirname(fullPath), { recursive: true });
10134
+ const fullPath = path18.join(vaultPath2, notePath);
10135
+ await fs19.mkdir(path18.dirname(fullPath), { recursive: true });
9500
10136
  const templates = config.templates || {};
9501
- const filename = path17.basename(notePath, ".md").toLowerCase();
10137
+ const filename = path18.basename(notePath, ".md").toLowerCase();
9502
10138
  let templatePath;
9503
10139
  const dailyPattern = /^\d{4}-\d{2}-\d{2}/;
9504
10140
  const weeklyPattern = /^\d{4}-W\d{2}/;
@@ -9519,10 +10155,10 @@ async function createNoteFromTemplate(vaultPath2, notePath, config) {
9519
10155
  let templateContent;
9520
10156
  if (templatePath) {
9521
10157
  try {
9522
- const absTemplatePath = path17.join(vaultPath2, templatePath);
9523
- templateContent = await fs17.readFile(absTemplatePath, "utf-8");
10158
+ const absTemplatePath = path18.join(vaultPath2, templatePath);
10159
+ templateContent = await fs19.readFile(absTemplatePath, "utf-8");
9524
10160
  } catch {
9525
- const title = path17.basename(notePath, ".md");
10161
+ const title = path18.basename(notePath, ".md");
9526
10162
  templateContent = `---
9527
10163
  ---
9528
10164
 
@@ -9531,7 +10167,7 @@ async function createNoteFromTemplate(vaultPath2, notePath, config) {
9531
10167
  templatePath = void 0;
9532
10168
  }
9533
10169
  } else {
9534
- const title = path17.basename(notePath, ".md");
10170
+ const title = path18.basename(notePath, ".md");
9535
10171
  templateContent = `---
9536
10172
  ---
9537
10173
 
@@ -9540,8 +10176,8 @@ async function createNoteFromTemplate(vaultPath2, notePath, config) {
9540
10176
  }
9541
10177
  const now = /* @__PURE__ */ new Date();
9542
10178
  const dateStr = now.toISOString().split("T")[0];
9543
- templateContent = templateContent.replace(/\{\{date\}\}/g, dateStr).replace(/\{\{title\}\}/g, path17.basename(notePath, ".md"));
9544
- 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");
9545
10181
  return { created: true, templateUsed: templatePath };
9546
10182
  }
9547
10183
  function registerMutationTools(server2, vaultPath2, getConfig = () => ({})) {
@@ -9572,9 +10208,9 @@ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", con
9572
10208
  let noteCreated = false;
9573
10209
  let templateUsed;
9574
10210
  if (create_if_missing) {
9575
- const fullPath = path17.join(vaultPath2, notePath);
10211
+ const fullPath = path18.join(vaultPath2, notePath);
9576
10212
  try {
9577
- await fs17.access(fullPath);
10213
+ await fs19.access(fullPath);
9578
10214
  } catch {
9579
10215
  const config = getConfig();
9580
10216
  const result = await createNoteFromTemplate(vaultPath2, notePath, config);
@@ -10009,8 +10645,8 @@ Example: vault_update_frontmatter({ path: "projects/alpha.md", frontmatter: { st
10009
10645
  // src/tools/write/notes.ts
10010
10646
  init_writer();
10011
10647
  import { z as z14 } from "zod";
10012
- import fs18 from "fs/promises";
10013
- import path18 from "path";
10648
+ import fs20 from "fs/promises";
10649
+ import path19 from "path";
10014
10650
  function registerNoteTools(server2, vaultPath2, getIndex) {
10015
10651
  server2.tool(
10016
10652
  "vault_create_note",
@@ -10033,23 +10669,23 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
10033
10669
  if (!validatePath(vaultPath2, notePath)) {
10034
10670
  return formatMcpResult(errorResult(notePath, "Invalid path: path traversal not allowed"));
10035
10671
  }
10036
- const fullPath = path18.join(vaultPath2, notePath);
10672
+ const fullPath = path19.join(vaultPath2, notePath);
10037
10673
  const existsCheck = await ensureFileExists(vaultPath2, notePath);
10038
10674
  if (existsCheck === null && !overwrite) {
10039
10675
  return formatMcpResult(errorResult(notePath, `File already exists: ${notePath}. Use overwrite=true to replace.`));
10040
10676
  }
10041
- const dir = path18.dirname(fullPath);
10042
- await fs18.mkdir(dir, { recursive: true });
10677
+ const dir = path19.dirname(fullPath);
10678
+ await fs20.mkdir(dir, { recursive: true });
10043
10679
  let effectiveContent = content;
10044
10680
  let effectiveFrontmatter = frontmatter;
10045
10681
  if (template) {
10046
- const templatePath = path18.join(vaultPath2, template);
10682
+ const templatePath = path19.join(vaultPath2, template);
10047
10683
  try {
10048
- const raw = await fs18.readFile(templatePath, "utf-8");
10684
+ const raw = await fs20.readFile(templatePath, "utf-8");
10049
10685
  const matter9 = (await import("gray-matter")).default;
10050
10686
  const parsed = matter9(raw);
10051
10687
  const dateStr = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
10052
- const title = path18.basename(notePath, ".md");
10688
+ const title = path19.basename(notePath, ".md");
10053
10689
  let templateContent = parsed.content.replace(/\{\{date\}\}/g, dateStr).replace(/\{\{title\}\}/g, title);
10054
10690
  if (content) {
10055
10691
  templateContent = templateContent.trimEnd() + "\n\n" + content;
@@ -10061,7 +10697,7 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
10061
10697
  }
10062
10698
  }
10063
10699
  const warnings = [];
10064
- const noteName = path18.basename(notePath, ".md");
10700
+ const noteName = path19.basename(notePath, ".md");
10065
10701
  const existingAliases = Array.isArray(effectiveFrontmatter?.aliases) ? effectiveFrontmatter.aliases.filter((a) => typeof a === "string") : [];
10066
10702
  const preflight = checkPreflightSimilarity(noteName);
10067
10703
  if (preflight.existingEntity) {
@@ -10178,8 +10814,8 @@ ${sources}`;
10178
10814
  }
10179
10815
  return formatMcpResult(errorResult(notePath, previewLines.join("\n")));
10180
10816
  }
10181
- const fullPath = path18.join(vaultPath2, notePath);
10182
- await fs18.unlink(fullPath);
10817
+ const fullPath = path19.join(vaultPath2, notePath);
10818
+ await fs20.unlink(fullPath);
10183
10819
  const gitInfo = await handleGitCommit(vaultPath2, notePath, commit, "[Flywheel:Delete]");
10184
10820
  const message = backlinkWarning ? `Deleted note: ${notePath}
10185
10821
 
@@ -10197,8 +10833,8 @@ Warning: ${backlinkWarning}` : `Deleted note: ${notePath}`;
10197
10833
  // src/tools/write/move-notes.ts
10198
10834
  init_writer();
10199
10835
  import { z as z15 } from "zod";
10200
- import fs19 from "fs/promises";
10201
- import path19 from "path";
10836
+ import fs21 from "fs/promises";
10837
+ import path20 from "path";
10202
10838
  import matter6 from "gray-matter";
10203
10839
  function escapeRegex(str) {
10204
10840
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -10217,16 +10853,16 @@ function extractWikilinks2(content) {
10217
10853
  return wikilinks;
10218
10854
  }
10219
10855
  function getTitleFromPath(filePath) {
10220
- return path19.basename(filePath, ".md");
10856
+ return path20.basename(filePath, ".md");
10221
10857
  }
10222
10858
  async function findBacklinks(vaultPath2, targetTitle, targetAliases) {
10223
10859
  const results = [];
10224
10860
  const allTargets = [targetTitle, ...targetAliases].map((t) => t.toLowerCase());
10225
10861
  async function scanDir(dir) {
10226
10862
  const files = [];
10227
- const entries = await fs19.readdir(dir, { withFileTypes: true });
10863
+ const entries = await fs21.readdir(dir, { withFileTypes: true });
10228
10864
  for (const entry of entries) {
10229
- const fullPath = path19.join(dir, entry.name);
10865
+ const fullPath = path20.join(dir, entry.name);
10230
10866
  if (entry.isDirectory() && !entry.name.startsWith(".")) {
10231
10867
  files.push(...await scanDir(fullPath));
10232
10868
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
@@ -10237,8 +10873,8 @@ async function findBacklinks(vaultPath2, targetTitle, targetAliases) {
10237
10873
  }
10238
10874
  const allFiles = await scanDir(vaultPath2);
10239
10875
  for (const filePath of allFiles) {
10240
- const relativePath = path19.relative(vaultPath2, filePath);
10241
- const content = await fs19.readFile(filePath, "utf-8");
10876
+ const relativePath = path20.relative(vaultPath2, filePath);
10877
+ const content = await fs21.readFile(filePath, "utf-8");
10242
10878
  const wikilinks = extractWikilinks2(content);
10243
10879
  const matchingLinks = [];
10244
10880
  for (const link of wikilinks) {
@@ -10257,8 +10893,8 @@ async function findBacklinks(vaultPath2, targetTitle, targetAliases) {
10257
10893
  return results;
10258
10894
  }
10259
10895
  async function updateBacklinksInFile(vaultPath2, filePath, oldTitles, newTitle) {
10260
- const fullPath = path19.join(vaultPath2, filePath);
10261
- const raw = await fs19.readFile(fullPath, "utf-8");
10896
+ const fullPath = path20.join(vaultPath2, filePath);
10897
+ const raw = await fs21.readFile(fullPath, "utf-8");
10262
10898
  const parsed = matter6(raw);
10263
10899
  let content = parsed.content;
10264
10900
  let totalUpdated = 0;
@@ -10324,10 +10960,10 @@ function registerMoveNoteTools(server2, vaultPath2) {
10324
10960
  };
10325
10961
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
10326
10962
  }
10327
- const oldFullPath = path19.join(vaultPath2, oldPath);
10328
- const newFullPath = path19.join(vaultPath2, newPath);
10963
+ const oldFullPath = path20.join(vaultPath2, oldPath);
10964
+ const newFullPath = path20.join(vaultPath2, newPath);
10329
10965
  try {
10330
- await fs19.access(oldFullPath);
10966
+ await fs21.access(oldFullPath);
10331
10967
  } catch {
10332
10968
  const result2 = {
10333
10969
  success: false,
@@ -10337,7 +10973,7 @@ function registerMoveNoteTools(server2, vaultPath2) {
10337
10973
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
10338
10974
  }
10339
10975
  try {
10340
- await fs19.access(newFullPath);
10976
+ await fs21.access(newFullPath);
10341
10977
  const result2 = {
10342
10978
  success: false,
10343
10979
  message: `Destination already exists: ${newPath}`,
@@ -10346,7 +10982,7 @@ function registerMoveNoteTools(server2, vaultPath2) {
10346
10982
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
10347
10983
  } catch {
10348
10984
  }
10349
- const sourceContent = await fs19.readFile(oldFullPath, "utf-8");
10985
+ const sourceContent = await fs21.readFile(oldFullPath, "utf-8");
10350
10986
  const parsed = matter6(sourceContent);
10351
10987
  const aliases = extractAliases2(parsed.data);
10352
10988
  const oldTitle = getTitleFromPath(oldPath);
@@ -10375,9 +11011,9 @@ function registerMoveNoteTools(server2, vaultPath2) {
10375
11011
  }
10376
11012
  }
10377
11013
  }
10378
- const destDir = path19.dirname(newFullPath);
10379
- await fs19.mkdir(destDir, { recursive: true });
10380
- await fs19.rename(oldFullPath, newFullPath);
11014
+ const destDir = path20.dirname(newFullPath);
11015
+ await fs21.mkdir(destDir, { recursive: true });
11016
+ await fs21.rename(oldFullPath, newFullPath);
10381
11017
  let gitCommit;
10382
11018
  let undoAvailable;
10383
11019
  let staleLockDetected;
@@ -10461,12 +11097,12 @@ function registerMoveNoteTools(server2, vaultPath2) {
10461
11097
  if (sanitizedTitle !== newTitle) {
10462
11098
  console.error(`[Flywheel] Title sanitized: "${newTitle}" \u2192 "${sanitizedTitle}"`);
10463
11099
  }
10464
- const fullPath = path19.join(vaultPath2, notePath);
10465
- const dir = path19.dirname(notePath);
10466
- const newPath = dir === "." ? `${sanitizedTitle}.md` : path19.join(dir, `${sanitizedTitle}.md`);
10467
- 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);
10468
11104
  try {
10469
- await fs19.access(fullPath);
11105
+ await fs21.access(fullPath);
10470
11106
  } catch {
10471
11107
  const result2 = {
10472
11108
  success: false,
@@ -10477,7 +11113,7 @@ function registerMoveNoteTools(server2, vaultPath2) {
10477
11113
  }
10478
11114
  if (fullPath !== newFullPath) {
10479
11115
  try {
10480
- await fs19.access(newFullPath);
11116
+ await fs21.access(newFullPath);
10481
11117
  const result2 = {
10482
11118
  success: false,
10483
11119
  message: `A note with this title already exists: ${newPath}`,
@@ -10487,7 +11123,7 @@ function registerMoveNoteTools(server2, vaultPath2) {
10487
11123
  } catch {
10488
11124
  }
10489
11125
  }
10490
- const sourceContent = await fs19.readFile(fullPath, "utf-8");
11126
+ const sourceContent = await fs21.readFile(fullPath, "utf-8");
10491
11127
  const parsed = matter6(sourceContent);
10492
11128
  const aliases = extractAliases2(parsed.data);
10493
11129
  const oldTitle = getTitleFromPath(notePath);
@@ -10516,7 +11152,7 @@ function registerMoveNoteTools(server2, vaultPath2) {
10516
11152
  }
10517
11153
  }
10518
11154
  if (fullPath !== newFullPath) {
10519
- await fs19.rename(fullPath, newFullPath);
11155
+ await fs21.rename(fullPath, newFullPath);
10520
11156
  }
10521
11157
  let gitCommit;
10522
11158
  let undoAvailable;
@@ -10689,8 +11325,8 @@ init_schema();
10689
11325
 
10690
11326
  // src/core/write/policy/parser.ts
10691
11327
  init_schema();
10692
- import fs20 from "fs/promises";
10693
- import path20 from "path";
11328
+ import fs22 from "fs/promises";
11329
+ import path21 from "path";
10694
11330
  import matter7 from "gray-matter";
10695
11331
  function parseYaml(content) {
10696
11332
  const parsed = matter7(`---
@@ -10715,7 +11351,7 @@ function parsePolicyString(yamlContent) {
10715
11351
  }
10716
11352
  async function loadPolicyFile(filePath) {
10717
11353
  try {
10718
- const content = await fs20.readFile(filePath, "utf-8");
11354
+ const content = await fs22.readFile(filePath, "utf-8");
10719
11355
  return parsePolicyString(content);
10720
11356
  } catch (error) {
10721
11357
  if (error.code === "ENOENT") {
@@ -10739,15 +11375,15 @@ async function loadPolicyFile(filePath) {
10739
11375
  }
10740
11376
  }
10741
11377
  async function loadPolicy(vaultPath2, policyName) {
10742
- const policiesDir = path20.join(vaultPath2, ".claude", "policies");
10743
- const policyPath = path20.join(policiesDir, `${policyName}.yaml`);
11378
+ const policiesDir = path21.join(vaultPath2, ".claude", "policies");
11379
+ const policyPath = path21.join(policiesDir, `${policyName}.yaml`);
10744
11380
  try {
10745
- await fs20.access(policyPath);
11381
+ await fs22.access(policyPath);
10746
11382
  return loadPolicyFile(policyPath);
10747
11383
  } catch {
10748
- const ymlPath = path20.join(policiesDir, `${policyName}.yml`);
11384
+ const ymlPath = path21.join(policiesDir, `${policyName}.yml`);
10749
11385
  try {
10750
- await fs20.access(ymlPath);
11386
+ await fs22.access(ymlPath);
10751
11387
  return loadPolicyFile(ymlPath);
10752
11388
  } catch {
10753
11389
  return {
@@ -10885,8 +11521,8 @@ init_template();
10885
11521
  init_conditions();
10886
11522
  init_schema();
10887
11523
  init_writer();
10888
- import fs22 from "fs/promises";
10889
- import path22 from "path";
11524
+ import fs24 from "fs/promises";
11525
+ import path23 from "path";
10890
11526
  init_constants();
10891
11527
  async function executeStep(step, vaultPath2, context, conditionResults) {
10892
11528
  const { execute, reason } = shouldStepExecute(step.when, conditionResults);
@@ -10955,9 +11591,9 @@ async function executeAddToSection(params, vaultPath2, context) {
10955
11591
  const preserveListNesting = params.preserveListNesting !== false;
10956
11592
  const suggestOutgoingLinks = params.suggestOutgoingLinks !== false;
10957
11593
  const maxSuggestions = Number(params.maxSuggestions) || 3;
10958
- const fullPath = path22.join(vaultPath2, notePath);
11594
+ const fullPath = path23.join(vaultPath2, notePath);
10959
11595
  try {
10960
- await fs22.access(fullPath);
11596
+ await fs24.access(fullPath);
10961
11597
  } catch {
10962
11598
  return { success: false, message: `File not found: ${notePath}`, path: notePath };
10963
11599
  }
@@ -10995,9 +11631,9 @@ async function executeRemoveFromSection(params, vaultPath2) {
10995
11631
  const pattern = String(params.pattern || "");
10996
11632
  const mode = params.mode || "first";
10997
11633
  const useRegex = Boolean(params.useRegex);
10998
- const fullPath = path22.join(vaultPath2, notePath);
11634
+ const fullPath = path23.join(vaultPath2, notePath);
10999
11635
  try {
11000
- await fs22.access(fullPath);
11636
+ await fs24.access(fullPath);
11001
11637
  } catch {
11002
11638
  return { success: false, message: `File not found: ${notePath}`, path: notePath };
11003
11639
  }
@@ -11026,9 +11662,9 @@ async function executeReplaceInSection(params, vaultPath2, context) {
11026
11662
  const mode = params.mode || "first";
11027
11663
  const useRegex = Boolean(params.useRegex);
11028
11664
  const skipWikilinks = Boolean(params.skipWikilinks);
11029
- const fullPath = path22.join(vaultPath2, notePath);
11665
+ const fullPath = path23.join(vaultPath2, notePath);
11030
11666
  try {
11031
- await fs22.access(fullPath);
11667
+ await fs24.access(fullPath);
11032
11668
  } catch {
11033
11669
  return { success: false, message: `File not found: ${notePath}`, path: notePath };
11034
11670
  }
@@ -11069,16 +11705,16 @@ async function executeCreateNote(params, vaultPath2, context) {
11069
11705
  if (!validatePath(vaultPath2, notePath)) {
11070
11706
  return { success: false, message: "Invalid path: path traversal not allowed", path: notePath };
11071
11707
  }
11072
- const fullPath = path22.join(vaultPath2, notePath);
11708
+ const fullPath = path23.join(vaultPath2, notePath);
11073
11709
  try {
11074
- await fs22.access(fullPath);
11710
+ await fs24.access(fullPath);
11075
11711
  if (!overwrite) {
11076
11712
  return { success: false, message: `File already exists: ${notePath}`, path: notePath };
11077
11713
  }
11078
11714
  } catch {
11079
11715
  }
11080
- const dir = path22.dirname(fullPath);
11081
- await fs22.mkdir(dir, { recursive: true });
11716
+ const dir = path23.dirname(fullPath);
11717
+ await fs24.mkdir(dir, { recursive: true });
11082
11718
  const { content: processedContent } = maybeApplyWikilinks(content, skipWikilinks, notePath);
11083
11719
  await writeVaultFile(vaultPath2, notePath, processedContent, frontmatter);
11084
11720
  return {
@@ -11097,13 +11733,13 @@ async function executeDeleteNote(params, vaultPath2) {
11097
11733
  if (!validatePath(vaultPath2, notePath)) {
11098
11734
  return { success: false, message: "Invalid path: path traversal not allowed", path: notePath };
11099
11735
  }
11100
- const fullPath = path22.join(vaultPath2, notePath);
11736
+ const fullPath = path23.join(vaultPath2, notePath);
11101
11737
  try {
11102
- await fs22.access(fullPath);
11738
+ await fs24.access(fullPath);
11103
11739
  } catch {
11104
11740
  return { success: false, message: `File not found: ${notePath}`, path: notePath };
11105
11741
  }
11106
- await fs22.unlink(fullPath);
11742
+ await fs24.unlink(fullPath);
11107
11743
  return {
11108
11744
  success: true,
11109
11745
  message: `Deleted note: ${notePath}`,
@@ -11114,9 +11750,9 @@ async function executeToggleTask(params, vaultPath2) {
11114
11750
  const notePath = String(params.path || "");
11115
11751
  const task = String(params.task || "");
11116
11752
  const section = params.section ? String(params.section) : void 0;
11117
- const fullPath = path22.join(vaultPath2, notePath);
11753
+ const fullPath = path23.join(vaultPath2, notePath);
11118
11754
  try {
11119
- await fs22.access(fullPath);
11755
+ await fs24.access(fullPath);
11120
11756
  } catch {
11121
11757
  return { success: false, message: `File not found: ${notePath}`, path: notePath };
11122
11758
  }
@@ -11157,9 +11793,9 @@ async function executeAddTask(params, vaultPath2, context) {
11157
11793
  const completed = Boolean(params.completed);
11158
11794
  const skipWikilinks = Boolean(params.skipWikilinks);
11159
11795
  const preserveListNesting = params.preserveListNesting !== false;
11160
- const fullPath = path22.join(vaultPath2, notePath);
11796
+ const fullPath = path23.join(vaultPath2, notePath);
11161
11797
  try {
11162
- await fs22.access(fullPath);
11798
+ await fs24.access(fullPath);
11163
11799
  } catch {
11164
11800
  return { success: false, message: `File not found: ${notePath}`, path: notePath };
11165
11801
  }
@@ -11194,9 +11830,9 @@ async function executeAddTask(params, vaultPath2, context) {
11194
11830
  async function executeUpdateFrontmatter(params, vaultPath2) {
11195
11831
  const notePath = String(params.path || "");
11196
11832
  const updates = params.frontmatter || {};
11197
- const fullPath = path22.join(vaultPath2, notePath);
11833
+ const fullPath = path23.join(vaultPath2, notePath);
11198
11834
  try {
11199
- await fs22.access(fullPath);
11835
+ await fs24.access(fullPath);
11200
11836
  } catch {
11201
11837
  return { success: false, message: `File not found: ${notePath}`, path: notePath };
11202
11838
  }
@@ -11216,9 +11852,9 @@ async function executeAddFrontmatterField(params, vaultPath2) {
11216
11852
  const notePath = String(params.path || "");
11217
11853
  const key = String(params.key || "");
11218
11854
  const value = params.value;
11219
- const fullPath = path22.join(vaultPath2, notePath);
11855
+ const fullPath = path23.join(vaultPath2, notePath);
11220
11856
  try {
11221
- await fs22.access(fullPath);
11857
+ await fs24.access(fullPath);
11222
11858
  } catch {
11223
11859
  return { success: false, message: `File not found: ${notePath}`, path: notePath };
11224
11860
  }
@@ -11376,15 +12012,15 @@ async function executePolicy(policy, vaultPath2, variables, commit = false) {
11376
12012
  async function rollbackChanges(vaultPath2, originalContents, filesModified) {
11377
12013
  for (const filePath of filesModified) {
11378
12014
  const original = originalContents.get(filePath);
11379
- const fullPath = path22.join(vaultPath2, filePath);
12015
+ const fullPath = path23.join(vaultPath2, filePath);
11380
12016
  if (original === null) {
11381
12017
  try {
11382
- await fs22.unlink(fullPath);
12018
+ await fs24.unlink(fullPath);
11383
12019
  } catch {
11384
12020
  }
11385
12021
  } else if (original !== void 0) {
11386
12022
  try {
11387
- await fs22.writeFile(fullPath, original);
12023
+ await fs24.writeFile(fullPath, original);
11388
12024
  } catch {
11389
12025
  }
11390
12026
  }
@@ -11430,27 +12066,27 @@ async function previewPolicy(policy, vaultPath2, variables) {
11430
12066
  }
11431
12067
 
11432
12068
  // src/core/write/policy/storage.ts
11433
- import fs23 from "fs/promises";
11434
- import path23 from "path";
12069
+ import fs25 from "fs/promises";
12070
+ import path24 from "path";
11435
12071
  function getPoliciesDir(vaultPath2) {
11436
- return path23.join(vaultPath2, ".claude", "policies");
12072
+ return path24.join(vaultPath2, ".claude", "policies");
11437
12073
  }
11438
12074
  async function ensurePoliciesDir(vaultPath2) {
11439
12075
  const dir = getPoliciesDir(vaultPath2);
11440
- await fs23.mkdir(dir, { recursive: true });
12076
+ await fs25.mkdir(dir, { recursive: true });
11441
12077
  }
11442
12078
  async function listPolicies(vaultPath2) {
11443
12079
  const dir = getPoliciesDir(vaultPath2);
11444
12080
  const policies = [];
11445
12081
  try {
11446
- const files = await fs23.readdir(dir);
12082
+ const files = await fs25.readdir(dir);
11447
12083
  for (const file of files) {
11448
12084
  if (!file.endsWith(".yaml") && !file.endsWith(".yml")) {
11449
12085
  continue;
11450
12086
  }
11451
- const filePath = path23.join(dir, file);
11452
- const stat3 = await fs23.stat(filePath);
11453
- 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");
11454
12090
  const metadata = extractPolicyMetadata(content);
11455
12091
  policies.push({
11456
12092
  name: metadata.name || file.replace(/\.ya?ml$/, ""),
@@ -11473,10 +12109,10 @@ async function writePolicyRaw(vaultPath2, policyName, content, overwrite = false
11473
12109
  const dir = getPoliciesDir(vaultPath2);
11474
12110
  await ensurePoliciesDir(vaultPath2);
11475
12111
  const filename = `${policyName}.yaml`;
11476
- const filePath = path23.join(dir, filename);
12112
+ const filePath = path24.join(dir, filename);
11477
12113
  if (!overwrite) {
11478
12114
  try {
11479
- await fs23.access(filePath);
12115
+ await fs25.access(filePath);
11480
12116
  return {
11481
12117
  success: false,
11482
12118
  path: filename,
@@ -11493,7 +12129,7 @@ async function writePolicyRaw(vaultPath2, policyName, content, overwrite = false
11493
12129
  message: `Invalid policy: ${validation.errors.map((e) => e.message).join("; ")}`
11494
12130
  };
11495
12131
  }
11496
- await fs23.writeFile(filePath, content, "utf-8");
12132
+ await fs25.writeFile(filePath, content, "utf-8");
11497
12133
  return {
11498
12134
  success: true,
11499
12135
  path: filename,
@@ -12016,8 +12652,8 @@ function registerPolicyTools(server2, vaultPath2) {
12016
12652
  import { z as z19 } from "zod";
12017
12653
 
12018
12654
  // src/core/write/tagRename.ts
12019
- import * as fs24 from "fs/promises";
12020
- import * as path24 from "path";
12655
+ import * as fs26 from "fs/promises";
12656
+ import * as path25 from "path";
12021
12657
  import matter8 from "gray-matter";
12022
12658
  import { getProtectedZones } from "@velvetmonkey/vault-core";
12023
12659
  function getNotesInFolder3(index, folder) {
@@ -12123,10 +12759,10 @@ async function renameTag(index, vaultPath2, oldTag, newTag, options) {
12123
12759
  const previews = [];
12124
12760
  let totalChanges = 0;
12125
12761
  for (const note of affectedNotes) {
12126
- const fullPath = path24.join(vaultPath2, note.path);
12762
+ const fullPath = path25.join(vaultPath2, note.path);
12127
12763
  let fileContent;
12128
12764
  try {
12129
- fileContent = await fs24.readFile(fullPath, "utf-8");
12765
+ fileContent = await fs26.readFile(fullPath, "utf-8");
12130
12766
  } catch {
12131
12767
  continue;
12132
12768
  }
@@ -12186,385 +12822,987 @@ async function renameTag(index, vaultPath2, oldTag, newTag, options) {
12186
12822
  fm.tags = newTags;
12187
12823
  }
12188
12824
  }
12189
- const { content: updatedContent, changes: contentChanges } = replaceInlineTags(
12190
- parsed.content,
12191
- cleanOld,
12192
- cleanNew,
12193
- renameChildren
12194
- );
12195
- preview.content_changes = contentChanges;
12196
- preview.total_changes = preview.frontmatter_changes.length + preview.content_changes.length;
12197
- totalChanges += preview.total_changes;
12198
- if (preview.total_changes > 0) {
12199
- previews.push(preview);
12200
- if (!dryRun) {
12201
- const newContent = matter8.stringify(updatedContent, fm);
12202
- await fs24.writeFile(fullPath, newContent, "utf-8");
12203
- }
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);
13004
+ }
13005
+ }
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;
12204
13028
  }
13029
+ wikilinkSuppressedCount = getSuppressedCount(stateDb2);
12205
13030
  }
12206
13031
  return {
12207
- old_tag: cleanOld,
12208
- new_tag: cleanNew,
12209
- rename_children: renameChildren,
12210
- dry_run: dryRun,
12211
- affected_notes: previews.length,
12212
- total_changes: totalChanges,
12213
- 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
12214
13043
  };
12215
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
+ }
12216
13125
 
12217
- // src/tools/write/tags.ts
12218
- function registerTagTools(server2, getIndex, getVaultPath) {
13126
+ // src/tools/read/metrics.ts
13127
+ function registerMetricsTools(server2, getIndex, getStateDb) {
12219
13128
  server2.registerTool(
12220
- "rename_tag",
13129
+ "vault_growth",
12221
13130
  {
12222
- title: "Rename Tag",
12223
- 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.',
12224
13133
  inputSchema: {
12225
- old_tag: z19.string().describe('Tag to rename (without #, e.g., "project")'),
12226
- new_tag: z19.string().describe('New tag name (without #, e.g., "work")'),
12227
- rename_children: z19.boolean().optional().describe("Also rename child tags (e.g., #project/active \u2192 #work/active). Default: true"),
12228
- folder: z19.string().optional().describe('Limit to notes in this folder (e.g., "projects")'),
12229
- dry_run: z19.boolean().optional().describe("Preview only, no changes (default: true)"),
12230
- 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)")
12231
13138
  }
12232
13139
  },
12233
- async ({ old_tag, new_tag, rename_children, folder, dry_run, commit }) => {
13140
+ async ({ mode, metric, days_back, limit: eventLimit }) => {
12234
13141
  const index = getIndex();
12235
- const vaultPath2 = getVaultPath();
12236
- const result = await renameTag(index, vaultPath2, old_tag, new_tag, {
12237
- rename_children: rename_children ?? true,
12238
- folder,
12239
- dry_run: dry_run ?? true,
12240
- commit: commit ?? false
12241
- });
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
+ }
12242
13197
  return {
12243
13198
  content: [
12244
13199
  {
12245
13200
  type: "text",
12246
13201
  text: JSON.stringify(result, null, 2)
12247
13202
  }
12248
- ]
12249
- };
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
+ }
12250
13317
  }
12251
- );
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;
12252
13360
  }
12253
13361
 
12254
- // src/tools/write/wikilinkFeedback.ts
12255
- import { z as z20 } from "zod";
12256
- function registerWikilinkFeedbackTools(server2, getStateDb) {
13362
+ // src/tools/read/activity.ts
13363
+ function registerActivityTools(server2, getStateDb, getSessionId2) {
12257
13364
  server2.registerTool(
12258
- "wikilink_feedback",
13365
+ "vault_activity",
12259
13366
  {
12260
- title: "Wikilink Feedback",
12261
- 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)',
12262
13369
  inputSchema: {
12263
- mode: z20.enum(["report", "list", "stats"]).describe("Operation mode"),
12264
- entity: z20.string().optional().describe("Entity name (required for report mode, optional filter for list/stats)"),
12265
- note_path: z20.string().optional().describe("Note path where the wikilink appeared (for report mode)"),
12266
- context: z20.string().optional().describe("Surrounding text context (for report mode)"),
12267
- correct: z20.boolean().optional().describe("Whether the wikilink was correct (for report mode)"),
12268
- 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)")
12269
13374
  }
12270
13375
  },
12271
- async ({ mode, entity, note_path, context, correct, limit }) => {
13376
+ async ({ mode, session_id, days_back, limit: resultLimit }) => {
12272
13377
  const stateDb2 = getStateDb();
12273
13378
  if (!stateDb2) {
12274
13379
  return {
12275
13380
  content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available" }) }]
12276
13381
  };
12277
13382
  }
12278
- let result;
13383
+ const daysBack = days_back ?? 30;
13384
+ const limit = resultLimit ?? 20;
12279
13385
  switch (mode) {
12280
- case "report": {
12281
- if (!entity || correct === void 0) {
13386
+ case "session": {
13387
+ const sid = session_id ?? getSessionId2();
13388
+ if (!sid) {
12282
13389
  return {
12283
- 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" }) }]
12284
13391
  };
12285
13392
  }
12286
- recordFeedback(stateDb2, entity, context || "", note_path || "", correct);
12287
- const suppressionUpdated = updateSuppressionList(stateDb2) > 0;
12288
- result = {
12289
- mode: "report",
12290
- reported: {
12291
- entity,
12292
- correct,
12293
- suppression_updated: suppressionUpdated
12294
- },
12295
- 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) }]
12296
13403
  };
12297
- break;
12298
13404
  }
12299
- case "list": {
12300
- const entries = getFeedback(stateDb2, entity, limit ?? 20);
12301
- result = {
12302
- mode: "list",
12303
- entries,
12304
- 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) }]
12305
13412
  };
12306
- break;
12307
13413
  }
12308
- case "stats": {
12309
- const stats = getEntityStats(stateDb2);
12310
- result = {
12311
- mode: "stats",
12312
- stats,
12313
- total_feedback: stats.reduce((sum, s) => sum + s.total, 0),
12314
- 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) }]
12315
13432
  };
12316
- break;
12317
13433
  }
12318
13434
  }
12319
- return {
12320
- content: [
12321
- {
12322
- type: "text",
12323
- text: JSON.stringify(result, null, 2)
12324
- }
12325
- ]
12326
- };
12327
13435
  }
12328
13436
  );
12329
13437
  }
12330
13438
 
12331
- // src/tools/read/metrics.ts
12332
- import { z as z21 } from "zod";
13439
+ // src/tools/read/similarity.ts
13440
+ import { z as z23 } from "zod";
12333
13441
 
12334
- // src/core/shared/metrics.ts
12335
- var ALL_METRICS = [
12336
- "note_count",
12337
- "link_count",
12338
- "orphan_count",
12339
- "tag_count",
12340
- "entity_count",
12341
- "avg_links_per_note",
12342
- "link_density",
12343
- "connected_ratio",
12344
- "wikilink_accuracy",
12345
- "wikilink_feedback_volume",
12346
- "wikilink_suppressed_count"
12347
- ];
12348
- function computeMetrics(index, stateDb2) {
12349
- const noteCount = index.notes.size;
12350
- let linkCount = 0;
12351
- for (const note of index.notes.values()) {
12352
- linkCount += note.outlinks.length;
12353
- }
12354
- const connectedNotes = /* @__PURE__ */ new Set();
12355
- for (const [notePath, note] of index.notes) {
12356
- if (note.outlinks.length > 0) {
12357
- connectedNotes.add(notePath);
12358
- }
12359
- }
12360
- for (const [target, backlinks] of index.backlinks) {
12361
- for (const bl of backlinks) {
12362
- connectedNotes.add(bl.source);
12363
- }
12364
- for (const note of index.notes.values()) {
12365
- const normalizedTitle = note.title.toLowerCase();
12366
- if (normalizedTitle === target.toLowerCase() || note.path.toLowerCase() === target.toLowerCase()) {
12367
- connectedNotes.add(note.path);
12368
- }
12369
- }
12370
- }
12371
- let orphanCount = 0;
12372
- for (const [notePath, note] of index.notes) {
12373
- if (!connectedNotes.has(notePath)) {
12374
- orphanCount++;
12375
- }
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 [];
12376
13587
  }
12377
- const tagCount = index.tags.size;
12378
- const entityCount = index.entities.size;
12379
- const avgLinksPerNote = noteCount > 0 ? linkCount / noteCount : 0;
12380
- const possibleLinks = noteCount * (noteCount - 1);
12381
- const linkDensity = possibleLinks > 0 ? linkCount / possibleLinks : 0;
12382
- const connectedRatio = noteCount > 0 ? connectedNotes.size / noteCount : 0;
12383
- let wikilinkAccuracy = 0;
12384
- let wikilinkFeedbackVolume = 0;
12385
- let wikilinkSuppressedCount = 0;
12386
- if (stateDb2) {
12387
- const entityStatsList = getEntityStats(stateDb2);
12388
- wikilinkFeedbackVolume = entityStatsList.reduce((sum, s) => sum + s.total, 0);
12389
- if (wikilinkFeedbackVolume > 0) {
12390
- const totalCorrect = entityStatsList.reduce((sum, s) => sum + s.correct, 0);
12391
- wikilinkAccuracy = Math.round(totalCorrect / wikilinkFeedbackVolume * 1e3) / 1e3;
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
+ }
12392
13619
  }
12393
- wikilinkSuppressedCount = getSuppressedCount(stateDb2);
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 [];
12394
13628
  }
12395
- return {
12396
- note_count: noteCount,
12397
- link_count: linkCount,
12398
- orphan_count: orphanCount,
12399
- tag_count: tagCount,
12400
- entity_count: entityCount,
12401
- avg_links_per_note: Math.round(avgLinksPerNote * 100) / 100,
12402
- link_density: Math.round(linkDensity * 1e4) / 1e4,
12403
- connected_ratio: Math.round(connectedRatio * 1e3) / 1e3,
12404
- wikilink_accuracy: wikilinkAccuracy,
12405
- wikilink_feedback_volume: wikilinkFeedbackVolume,
12406
- wikilink_suppressed_count: wikilinkSuppressedCount
12407
- };
12408
- }
12409
- function recordMetrics(stateDb2, metrics) {
12410
- const timestamp = Date.now();
12411
- const insert = stateDb2.db.prepare(
12412
- "INSERT INTO vault_metrics (timestamp, metric, value) VALUES (?, ?, ?)"
12413
- );
12414
- const transaction = stateDb2.db.transaction(() => {
12415
- for (const [metric, value] of Object.entries(metrics)) {
12416
- insert.run(timestamp, metric, value);
12417
- }
12418
- });
12419
- transaction();
12420
13629
  }
12421
- function getMetricHistory(stateDb2, metric, daysBack = 30) {
12422
- const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1e3;
12423
- let rows;
12424
- if (metric) {
12425
- rows = stateDb2.db.prepare(
12426
- "SELECT timestamp, metric, value FROM vault_metrics WHERE metric = ? AND timestamp >= ? ORDER BY timestamp"
12427
- ).all(metric, cutoff);
12428
- } else {
12429
- rows = stateDb2.db.prepare(
12430
- "SELECT timestamp, metric, value FROM vault_metrics WHERE timestamp >= ? ORDER BY timestamp"
12431
- ).all(cutoff);
12432
- }
12433
- return rows.map((r) => ({
12434
- metric: r.metric,
12435
- value: r.value,
12436
- timestamp: r.timestamp
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
12437
13658
  }));
12438
13659
  }
12439
- function computeTrends(stateDb2, currentMetrics, daysBack = 30) {
12440
- const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1e3;
12441
- const rows = stateDb2.db.prepare(`
12442
- SELECT metric, value FROM vault_metrics
12443
- WHERE timestamp >= ? AND timestamp <= ?
12444
- GROUP BY metric
12445
- HAVING timestamp = MIN(timestamp)
12446
- `).all(cutoff, cutoff + 24 * 60 * 60 * 1e3);
12447
- const previousValues = /* @__PURE__ */ new Map();
12448
- for (const row of rows) {
12449
- previousValues.set(row.metric, row.value);
12450
- }
12451
- if (previousValues.size === 0) {
12452
- const fallbackRows = stateDb2.db.prepare(`
12453
- SELECT metric, MIN(value) as value FROM vault_metrics
12454
- WHERE timestamp >= ?
12455
- GROUP BY metric
12456
- HAVING timestamp = MIN(timestamp)
12457
- `).all(cutoff);
12458
- for (const row of fallbackRows) {
12459
- previousValues.set(row.metric, row.value);
12460
- }
12461
- }
12462
- const trends = [];
12463
- for (const metricName of ALL_METRICS) {
12464
- const current = currentMetrics[metricName] ?? 0;
12465
- const previous = previousValues.get(metricName) ?? current;
12466
- const delta = current - previous;
12467
- const deltaPct = previous !== 0 ? Math.round(delta / previous * 1e4) / 100 : delta !== 0 ? 100 : 0;
12468
- let direction = "stable";
12469
- if (delta > 0) direction = "up";
12470
- if (delta < 0) direction = "down";
12471
- trends.push({
12472
- metric: metricName,
12473
- current,
12474
- previous,
12475
- delta,
12476
- delta_percent: deltaPct,
12477
- 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
12478
13671
  });
13672
+ } catch {
13673
+ return bm25Results.slice(0, limit);
12479
13674
  }
12480
- return trends;
12481
- }
12482
- function purgeOldMetrics(stateDb2, retentionDays = 90) {
12483
- const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1e3;
12484
- const result = stateDb2.db.prepare(
12485
- "DELETE FROM vault_metrics WHERE timestamp < ?"
12486
- ).run(cutoff);
12487
- 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);
12488
13694
  }
12489
13695
 
12490
- // src/tools/read/metrics.ts
12491
- function registerMetricsTools(server2, getIndex, getStateDb) {
13696
+ // src/tools/read/similarity.ts
13697
+ function registerSimilarityTools(server2, getIndex, getVaultPath, getStateDb) {
12492
13698
  server2.registerTool(
12493
- "vault_growth",
13699
+ "find_similar",
12494
13700
  {
12495
- title: "Vault Growth",
12496
- 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.',
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.",
12497
13703
  inputSchema: {
12498
- mode: z21.enum(["current", "history", "trends", "index_activity"]).describe("Query mode: current snapshot, historical time series, trend analysis, or index rebuild activity"),
12499
- metric: z21.string().optional().describe('Filter to specific metric (e.g., "note_count"). Omit for all metrics.'),
12500
- days_back: z21.number().optional().describe("Number of days to look back for history/trends (default: 30)"),
12501
- limit: z21.number().optional().describe("Number of recent events to return for index_activity mode (default: 20)")
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)")
12502
13707
  }
12503
13708
  },
12504
- async ({ mode, metric, days_back, limit: eventLimit }) => {
13709
+ async ({ path: path28, limit, exclude_linked }) => {
12505
13710
  const index = getIndex();
13711
+ const vaultPath2 = getVaultPath();
12506
13712
  const stateDb2 = getStateDb();
12507
- const daysBack = days_back ?? 30;
12508
- let result;
12509
- switch (mode) {
12510
- case "current": {
12511
- const metrics = computeMetrics(index, stateDb2 ?? void 0);
12512
- result = {
12513
- mode: "current",
12514
- metrics,
12515
- recorded_at: Date.now()
12516
- };
12517
- break;
12518
- }
12519
- case "history": {
12520
- if (!stateDb2) {
12521
- return {
12522
- content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available for historical queries" }) }]
12523
- };
12524
- }
12525
- const history = getMetricHistory(stateDb2, metric, daysBack);
12526
- result = {
12527
- mode: "history",
12528
- history
12529
- };
12530
- break;
12531
- }
12532
- case "trends": {
12533
- if (!stateDb2) {
12534
- return {
12535
- content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available for trend analysis" }) }]
12536
- };
12537
- }
12538
- const currentMetrics = computeMetrics(index, stateDb2);
12539
- const trends = computeTrends(stateDb2, currentMetrics, daysBack);
12540
- result = {
12541
- mode: "trends",
12542
- trends
12543
- };
12544
- break;
12545
- }
12546
- case "index_activity": {
12547
- if (!stateDb2) {
12548
- return {
12549
- content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available for index activity queries" }) }]
12550
- };
12551
- }
12552
- const summary = getIndexActivitySummary(stateDb2);
12553
- const recentEvents = getRecentIndexEvents(stateDb2, eventLimit ?? 20);
12554
- result = {
12555
- mode: "index_activity",
12556
- index_activity: { summary, recent_events: recentEvents }
12557
- };
12558
- break;
12559
- }
13713
+ if (!stateDb2) {
13714
+ return {
13715
+ content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available" }) }]
13716
+ };
12560
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
+ };
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);
12561
13733
  return {
12562
- content: [
12563
- {
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: [{
12564
13768
  type: "text",
12565
- text: JSON.stringify(result, null, 2)
12566
- }
12567
- ]
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
+ }]
12568
13806
  };
12569
13807
  }
12570
13808
  );
@@ -12763,6 +14001,7 @@ var TOOL_CATEGORY = {
12763
14001
  get_unlinked_mentions: "health",
12764
14002
  // search (unified: metadata + content + entities)
12765
14003
  search: "search",
14004
+ init_semantic: "search",
12766
14005
  // backlinks
12767
14006
  get_backlinks: "backlinks",
12768
14007
  get_forward_links: "backlinks",
@@ -12809,7 +14048,11 @@ var TOOL_CATEGORY = {
12809
14048
  // health (growth metrics)
12810
14049
  vault_growth: "health",
12811
14050
  // wikilinks (feedback)
12812
- wikilink_feedback: "wikilinks"
14051
+ wikilink_feedback: "wikilinks",
14052
+ // health (activity tracking)
14053
+ vault_activity: "health",
14054
+ // schema (content similarity)
14055
+ find_similar: "schema"
12813
14056
  };
12814
14057
  var server = new McpServer({
12815
14058
  name: "flywheel-memory",
@@ -12826,15 +14069,62 @@ function gateByCategory(name) {
12826
14069
  _registeredCount++;
12827
14070
  return true;
12828
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
+ }
12829
14113
  var _originalTool = server.tool.bind(server);
12830
14114
  server.tool = (name, ...args) => {
12831
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
+ }
12832
14119
  return _originalTool(name, ...args);
12833
14120
  };
12834
14121
  var _originalRegisterTool = server.registerTool?.bind(server);
12835
14122
  if (_originalRegisterTool) {
12836
14123
  server.registerTool = (name, ...args) => {
12837
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
+ }
12838
14128
  return _originalRegisterTool(name, ...args);
12839
14129
  };
12840
14130
  }
@@ -12857,7 +14147,7 @@ registerGraphTools(server, () => vaultIndex, () => vaultPath);
12857
14147
  registerWikilinkTools(server, () => vaultIndex, () => vaultPath);
12858
14148
  registerQueryTools(server, () => vaultIndex, () => vaultPath, () => stateDb);
12859
14149
  registerPrimitiveTools(server, () => vaultIndex, () => vaultPath, () => flywheelConfig);
12860
- registerGraphAnalysisTools(server, () => vaultIndex, () => vaultPath);
14150
+ registerGraphAnalysisTools(server, () => vaultIndex, () => vaultPath, () => stateDb);
12861
14151
  registerVaultSchemaTools(server, () => vaultIndex, () => vaultPath);
12862
14152
  registerNoteIntelligenceTools(server, () => vaultIndex, () => vaultPath);
12863
14153
  registerMigrationTools(server, () => vaultIndex, () => vaultPath);
@@ -12871,6 +14161,15 @@ registerPolicyTools(server, vaultPath);
12871
14161
  registerTagTools(server, () => vaultIndex, () => vaultPath);
12872
14162
  registerWikilinkFeedbackTools(server, () => stateDb);
12873
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);
12874
14173
  registerVaultResources(server, () => vaultIndex ?? null);
12875
14174
  console.error(`[Memory] Registered ${_registeredCount} tools, skipped ${_skippedCount}`);
12876
14175
  async function main() {
@@ -12881,6 +14180,7 @@ async function main() {
12881
14180
  stateDb = openStateDb(vaultPath);
12882
14181
  console.error("[Memory] StateDb initialized");
12883
14182
  setFTS5Database(stateDb.db);
14183
+ setEmbeddingsDatabase(stateDb.db);
12884
14184
  setWriteStateDb(stateDb);
12885
14185
  await initializeEntityIndex(vaultPath);
12886
14186
  } catch (err) {
@@ -13005,11 +14305,21 @@ async function runPostIndexWork(index) {
13005
14305
  recordMetrics(stateDb, metrics);
13006
14306
  purgeOldMetrics(stateDb, 90);
13007
14307
  purgeOldIndexEvents(stateDb, 90);
14308
+ purgeOldInvocations(stateDb, 90);
13008
14309
  console.error("[Memory] Growth metrics recorded");
13009
14310
  } catch (err) {
13010
14311
  console.error("[Memory] Failed to record metrics:", err);
13011
14312
  }
13012
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
+ }
13013
14323
  if (stateDb) {
13014
14324
  try {
13015
14325
  updateSuppressionList(stateDb);
@@ -13052,6 +14362,19 @@ async function runPostIndexWork(index) {
13052
14362
  }
13053
14363
  await updateEntitiesInStateDb();
13054
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
+ }
13055
14378
  if (stateDb) {
13056
14379
  try {
13057
14380
  saveVaultIndexToCache(stateDb, vaultIndex);
@@ -13088,10 +14411,32 @@ async function runPostIndexWork(index) {
13088
14411
  watcher.start();
13089
14412
  }
13090
14413
  }
13091
- main().catch((error) => {
13092
- console.error("[Memory] Fatal error:", error);
13093
- process.exit(1);
13094
- });
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
+ }
13095
14440
  process.on("beforeExit", async () => {
13096
14441
  await flushLogs();
13097
14442
  });