@velvetmonkey/flywheel-memory 2.0.34 → 2.0.36

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 +917 -247
  2. package/package.json +4 -2
package/dist/index.js CHANGED
@@ -59,7 +59,7 @@ var init_constants = __esm({
59
59
 
60
60
  // src/core/write/writer.ts
61
61
  import fs18 from "fs/promises";
62
- import path18 from "path";
62
+ import path19 from "path";
63
63
  import matter5 from "gray-matter";
64
64
  function isSensitivePath(filePath) {
65
65
  const normalizedPath = filePath.replace(/\\/g, "/");
@@ -386,8 +386,8 @@ function validatePath(vaultPath2, notePath) {
386
386
  if (notePath.startsWith("\\")) {
387
387
  return false;
388
388
  }
389
- const resolvedVault = path18.resolve(vaultPath2);
390
- const resolvedNote = path18.resolve(vaultPath2, notePath);
389
+ const resolvedVault = path19.resolve(vaultPath2);
390
+ const resolvedNote = path19.resolve(vaultPath2, notePath);
391
391
  return resolvedNote.startsWith(resolvedVault);
392
392
  }
393
393
  async function validatePathSecure(vaultPath2, notePath) {
@@ -415,8 +415,8 @@ async function validatePathSecure(vaultPath2, notePath) {
415
415
  reason: "Path traversal not allowed"
416
416
  };
417
417
  }
418
- const resolvedVault = path18.resolve(vaultPath2);
419
- const resolvedNote = path18.resolve(vaultPath2, notePath);
418
+ const resolvedVault = path19.resolve(vaultPath2);
419
+ const resolvedNote = path19.resolve(vaultPath2, notePath);
420
420
  if (!resolvedNote.startsWith(resolvedVault)) {
421
421
  return {
422
422
  valid: false,
@@ -430,7 +430,7 @@ async function validatePathSecure(vaultPath2, notePath) {
430
430
  };
431
431
  }
432
432
  try {
433
- const fullPath = path18.join(vaultPath2, notePath);
433
+ const fullPath = path19.join(vaultPath2, notePath);
434
434
  try {
435
435
  await fs18.access(fullPath);
436
436
  const realPath = await fs18.realpath(fullPath);
@@ -441,7 +441,7 @@ async function validatePathSecure(vaultPath2, notePath) {
441
441
  reason: "Symlink target is outside vault"
442
442
  };
443
443
  }
444
- const relativePath = path18.relative(realVaultPath, realPath);
444
+ const relativePath = path19.relative(realVaultPath, realPath);
445
445
  if (isSensitivePath(relativePath)) {
446
446
  return {
447
447
  valid: false,
@@ -449,7 +449,7 @@ async function validatePathSecure(vaultPath2, notePath) {
449
449
  };
450
450
  }
451
451
  } catch {
452
- const parentDir = path18.dirname(fullPath);
452
+ const parentDir = path19.dirname(fullPath);
453
453
  try {
454
454
  await fs18.access(parentDir);
455
455
  const realParentPath = await fs18.realpath(parentDir);
@@ -475,7 +475,7 @@ async function readVaultFile(vaultPath2, notePath) {
475
475
  if (!validatePath(vaultPath2, notePath)) {
476
476
  throw new Error("Invalid path: path traversal not allowed");
477
477
  }
478
- const fullPath = path18.join(vaultPath2, notePath);
478
+ const fullPath = path19.join(vaultPath2, notePath);
479
479
  const [rawContent, stat3] = await Promise.all([
480
480
  fs18.readFile(fullPath, "utf-8"),
481
481
  fs18.stat(fullPath)
@@ -528,7 +528,7 @@ async function writeVaultFile(vaultPath2, notePath, content, frontmatter, lineEn
528
528
  if (!validation.valid) {
529
529
  throw new Error(`Invalid path: ${validation.reason}`);
530
530
  }
531
- const fullPath = path18.join(vaultPath2, notePath);
531
+ const fullPath = path19.join(vaultPath2, notePath);
532
532
  let output = matter5.stringify(content, frontmatter);
533
533
  output = normalizeTrailingNewline(output);
534
534
  output = convertLineEndings(output, lineEnding);
@@ -832,11 +832,12 @@ function createContext(variables = {}) {
832
832
  today: formatDate2(now),
833
833
  time: formatTime(now),
834
834
  date: formatDate2(now)
835
- }
835
+ },
836
+ steps: {}
836
837
  };
837
838
  }
838
- function resolvePath(obj, path30) {
839
- const parts = path30.split(".");
839
+ function resolvePath(obj, path31) {
840
+ const parts = path31.split(".");
840
841
  let current = obj;
841
842
  for (const part of parts) {
842
843
  if (current === void 0 || current === null) {
@@ -878,6 +879,9 @@ function resolveExpression(expr, context) {
878
879
  if (trimmed.startsWith("builtins.")) {
879
880
  return resolvePath(context.builtins, trimmed.slice("builtins.".length));
880
881
  }
882
+ if (trimmed.startsWith("steps.")) {
883
+ return resolvePath(context.steps, trimmed.slice("steps.".length));
884
+ }
881
885
  return resolvePath(context.variables, trimmed);
882
886
  }
883
887
  function interpolate(template, context) {
@@ -1056,7 +1060,7 @@ function validatePolicySchema(policy) {
1056
1060
  const match = ref.match(/\{\{(?:variables\.)?(\w+)/);
1057
1061
  if (match) {
1058
1062
  const varName = match[1];
1059
- if (["now", "today", "time", "date", "conditions"].includes(varName)) {
1063
+ if (["now", "today", "time", "date", "conditions", "steps"].includes(varName)) {
1060
1064
  continue;
1061
1065
  }
1062
1066
  if (!varNames.has(varName)) {
@@ -1275,7 +1279,7 @@ __export(conditions_exports, {
1275
1279
  shouldStepExecute: () => shouldStepExecute
1276
1280
  });
1277
1281
  import fs25 from "fs/promises";
1278
- import path24 from "path";
1282
+ import path25 from "path";
1279
1283
  async function evaluateCondition(condition, vaultPath2, context) {
1280
1284
  const interpolatedPath = condition.path ? interpolate(condition.path, context) : void 0;
1281
1285
  const interpolatedSection = condition.section ? interpolate(condition.section, context) : void 0;
@@ -1328,7 +1332,7 @@ async function evaluateCondition(condition, vaultPath2, context) {
1328
1332
  }
1329
1333
  }
1330
1334
  async function evaluateFileExists(vaultPath2, notePath, expectExists) {
1331
- const fullPath = path24.join(vaultPath2, notePath);
1335
+ const fullPath = path25.join(vaultPath2, notePath);
1332
1336
  try {
1333
1337
  await fs25.access(fullPath);
1334
1338
  return {
@@ -1343,7 +1347,7 @@ async function evaluateFileExists(vaultPath2, notePath, expectExists) {
1343
1347
  }
1344
1348
  }
1345
1349
  async function evaluateSectionExists(vaultPath2, notePath, sectionName, expectExists) {
1346
- const fullPath = path24.join(vaultPath2, notePath);
1350
+ const fullPath = path25.join(vaultPath2, notePath);
1347
1351
  try {
1348
1352
  await fs25.access(fullPath);
1349
1353
  } catch {
@@ -1374,7 +1378,7 @@ async function evaluateSectionExists(vaultPath2, notePath, sectionName, expectEx
1374
1378
  }
1375
1379
  }
1376
1380
  async function evaluateFrontmatterExists(vaultPath2, notePath, fieldName, expectExists) {
1377
- const fullPath = path24.join(vaultPath2, notePath);
1381
+ const fullPath = path25.join(vaultPath2, notePath);
1378
1382
  try {
1379
1383
  await fs25.access(fullPath);
1380
1384
  } catch {
@@ -1405,7 +1409,7 @@ async function evaluateFrontmatterExists(vaultPath2, notePath, fieldName, expect
1405
1409
  }
1406
1410
  }
1407
1411
  async function evaluateFrontmatterEquals(vaultPath2, notePath, fieldName, expectedValue) {
1408
- const fullPath = path24.join(vaultPath2, notePath);
1412
+ const fullPath = path25.join(vaultPath2, notePath);
1409
1413
  try {
1410
1414
  await fs25.access(fullPath);
1411
1415
  } catch {
@@ -1548,7 +1552,10 @@ var init_taskHelpers = __esm({
1548
1552
  });
1549
1553
 
1550
1554
  // src/index.ts
1551
- import * as path29 from "path";
1555
+ import * as path30 from "path";
1556
+ import { readFileSync as readFileSync4, realpathSync } from "fs";
1557
+ import { fileURLToPath } from "url";
1558
+ import { dirname as dirname4, join as join16 } from "path";
1552
1559
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1553
1560
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1554
1561
 
@@ -2209,8 +2216,8 @@ function updateIndexProgress(parsed, total) {
2209
2216
  function normalizeTarget(target) {
2210
2217
  return target.toLowerCase().replace(/\.md$/, "");
2211
2218
  }
2212
- function normalizeNotePath(path30) {
2213
- return path30.toLowerCase().replace(/\.md$/, "");
2219
+ function normalizeNotePath(path31) {
2220
+ return path31.toLowerCase().replace(/\.md$/, "");
2214
2221
  }
2215
2222
  async function buildVaultIndex(vaultPath2, options = {}) {
2216
2223
  const { timeoutMs = DEFAULT_TIMEOUT_MS, onProgress } = options;
@@ -2260,7 +2267,10 @@ async function buildVaultIndexInternal(vaultPath2, startTime, onProgress) {
2260
2267
  }
2261
2268
  }
2262
2269
  if (parseErrors.length > 0) {
2263
- console.error(`Failed to parse ${parseErrors.length} files`);
2270
+ console.error(`Failed to parse ${parseErrors.length} files:`);
2271
+ for (const errorPath of parseErrors) {
2272
+ console.error(` - ${errorPath}`);
2273
+ }
2264
2274
  }
2265
2275
  const entities = /* @__PURE__ */ new Map();
2266
2276
  for (const note of notes.values()) {
@@ -2376,7 +2386,7 @@ function findSimilarEntity(index, target) {
2376
2386
  }
2377
2387
  const maxDist = normalizedLen <= 10 ? 1 : 2;
2378
2388
  let bestMatch;
2379
- for (const [entity, path30] of index.entities) {
2389
+ for (const [entity, path31] of index.entities) {
2380
2390
  const lenDiff = Math.abs(entity.length - normalizedLen);
2381
2391
  if (lenDiff > maxDist) {
2382
2392
  continue;
@@ -2384,7 +2394,7 @@ function findSimilarEntity(index, target) {
2384
2394
  const dist = levenshteinDistance(normalized, entity);
2385
2395
  if (dist > 0 && dist <= maxDist) {
2386
2396
  if (!bestMatch || dist < bestMatch.distance) {
2387
- bestMatch = { path: path30, entity, distance: dist };
2397
+ bestMatch = { path: path31, entity, distance: dist };
2388
2398
  if (dist === 1) {
2389
2399
  return bestMatch;
2390
2400
  }
@@ -2854,30 +2864,30 @@ var EventQueue = class {
2854
2864
  * Add a new event to the queue
2855
2865
  */
2856
2866
  push(type, rawPath) {
2857
- const path30 = normalizePath(rawPath);
2867
+ const path31 = normalizePath(rawPath);
2858
2868
  const now = Date.now();
2859
2869
  const event = {
2860
2870
  type,
2861
- path: path30,
2871
+ path: path31,
2862
2872
  timestamp: now
2863
2873
  };
2864
- let pending = this.pending.get(path30);
2874
+ let pending = this.pending.get(path31);
2865
2875
  if (!pending) {
2866
2876
  pending = {
2867
2877
  events: [],
2868
2878
  timer: null,
2869
2879
  lastEvent: now
2870
2880
  };
2871
- this.pending.set(path30, pending);
2881
+ this.pending.set(path31, pending);
2872
2882
  }
2873
2883
  pending.events.push(event);
2874
2884
  pending.lastEvent = now;
2875
- console.error(`[flywheel] QUEUE: pushed ${type} for ${path30}, pending=${this.pending.size}`);
2885
+ console.error(`[flywheel] QUEUE: pushed ${type} for ${path31}, pending=${this.pending.size}`);
2876
2886
  if (pending.timer) {
2877
2887
  clearTimeout(pending.timer);
2878
2888
  }
2879
2889
  pending.timer = setTimeout(() => {
2880
- this.flushPath(path30);
2890
+ this.flushPath(path31);
2881
2891
  }, this.config.debounceMs);
2882
2892
  if (this.pending.size >= this.config.batchSize) {
2883
2893
  this.flush();
@@ -2898,10 +2908,10 @@ var EventQueue = class {
2898
2908
  /**
2899
2909
  * Flush a single path's events
2900
2910
  */
2901
- flushPath(path30) {
2902
- const pending = this.pending.get(path30);
2911
+ flushPath(path31) {
2912
+ const pending = this.pending.get(path31);
2903
2913
  if (!pending || pending.events.length === 0) return;
2904
- console.error(`[flywheel] QUEUE: flushing ${path30}, events=${pending.events.length}`);
2914
+ console.error(`[flywheel] QUEUE: flushing ${path31}, events=${pending.events.length}`);
2905
2915
  if (pending.timer) {
2906
2916
  clearTimeout(pending.timer);
2907
2917
  pending.timer = null;
@@ -2910,7 +2920,7 @@ var EventQueue = class {
2910
2920
  if (coalescedType) {
2911
2921
  const coalesced = {
2912
2922
  type: coalescedType,
2913
- path: path30,
2923
+ path: path31,
2914
2924
  originalEvents: [...pending.events]
2915
2925
  };
2916
2926
  this.onBatch({
@@ -2918,7 +2928,7 @@ var EventQueue = class {
2918
2928
  timestamp: Date.now()
2919
2929
  });
2920
2930
  }
2921
- this.pending.delete(path30);
2931
+ this.pending.delete(path31);
2922
2932
  }
2923
2933
  /**
2924
2934
  * Flush all pending events
@@ -2930,7 +2940,7 @@ var EventQueue = class {
2930
2940
  }
2931
2941
  if (this.pending.size === 0) return;
2932
2942
  const events = [];
2933
- for (const [path30, pending] of this.pending) {
2943
+ for (const [path31, pending] of this.pending) {
2934
2944
  if (pending.timer) {
2935
2945
  clearTimeout(pending.timer);
2936
2946
  }
@@ -2938,7 +2948,7 @@ var EventQueue = class {
2938
2948
  if (coalescedType) {
2939
2949
  events.push({
2940
2950
  type: coalescedType,
2941
- path: path30,
2951
+ path: path31,
2942
2952
  originalEvents: [...pending.events]
2943
2953
  });
2944
2954
  }
@@ -3013,6 +3023,208 @@ function parseWatcherConfig() {
3013
3023
  };
3014
3024
  }
3015
3025
 
3026
+ // src/core/read/watch/incrementalIndex.ts
3027
+ import path6 from "path";
3028
+ function normalizeTarget2(target) {
3029
+ return target.toLowerCase().replace(/\.md$/, "");
3030
+ }
3031
+ function normalizeNotePath2(notePath) {
3032
+ return notePath.toLowerCase().replace(/\.md$/, "");
3033
+ }
3034
+ function removeNoteFromIndex(index, notePath) {
3035
+ const note = index.notes.get(notePath);
3036
+ if (!note) {
3037
+ return false;
3038
+ }
3039
+ index.notes.delete(notePath);
3040
+ const normalizedTitle = normalizeTarget2(note.title);
3041
+ const normalizedPath = normalizeNotePath2(notePath);
3042
+ if (index.entities.get(normalizedTitle) === notePath) {
3043
+ index.entities.delete(normalizedTitle);
3044
+ }
3045
+ if (index.entities.get(normalizedPath) === notePath) {
3046
+ index.entities.delete(normalizedPath);
3047
+ }
3048
+ for (const alias of note.aliases) {
3049
+ const normalizedAlias = normalizeTarget2(alias);
3050
+ if (index.entities.get(normalizedAlias) === notePath) {
3051
+ index.entities.delete(normalizedAlias);
3052
+ }
3053
+ }
3054
+ for (const tag of note.tags) {
3055
+ const tagPaths = index.tags.get(tag);
3056
+ if (tagPaths) {
3057
+ tagPaths.delete(notePath);
3058
+ if (tagPaths.size === 0) {
3059
+ index.tags.delete(tag);
3060
+ }
3061
+ }
3062
+ }
3063
+ for (const link of note.outlinks) {
3064
+ const normalizedTarget = normalizeTarget2(link.target);
3065
+ const targetPath = index.entities.get(normalizedTarget);
3066
+ const key = targetPath ? normalizeNotePath2(targetPath) : normalizedTarget;
3067
+ const backlinks = index.backlinks.get(key);
3068
+ if (backlinks) {
3069
+ const filtered = backlinks.filter((bl) => bl.source !== notePath);
3070
+ if (filtered.length === 0) {
3071
+ index.backlinks.delete(key);
3072
+ } else {
3073
+ index.backlinks.set(key, filtered);
3074
+ }
3075
+ }
3076
+ }
3077
+ return true;
3078
+ }
3079
+ function addNoteToIndex(index, note) {
3080
+ index.notes.set(note.path, note);
3081
+ const normalizedTitle = normalizeTarget2(note.title);
3082
+ const normalizedPath = normalizeNotePath2(note.path);
3083
+ if (!index.entities.has(normalizedTitle)) {
3084
+ index.entities.set(normalizedTitle, note.path);
3085
+ }
3086
+ index.entities.set(normalizedPath, note.path);
3087
+ for (const alias of note.aliases) {
3088
+ const normalizedAlias = normalizeTarget2(alias);
3089
+ if (!index.entities.has(normalizedAlias)) {
3090
+ index.entities.set(normalizedAlias, note.path);
3091
+ }
3092
+ }
3093
+ for (const tag of note.tags) {
3094
+ if (!index.tags.has(tag)) {
3095
+ index.tags.set(tag, /* @__PURE__ */ new Set());
3096
+ }
3097
+ index.tags.get(tag).add(note.path);
3098
+ }
3099
+ for (const link of note.outlinks) {
3100
+ const normalizedTarget = normalizeTarget2(link.target);
3101
+ const targetPath = index.entities.get(normalizedTarget);
3102
+ const key = targetPath ? normalizeNotePath2(targetPath) : normalizedTarget;
3103
+ if (!index.backlinks.has(key)) {
3104
+ index.backlinks.set(key, []);
3105
+ }
3106
+ index.backlinks.get(key).push({
3107
+ source: note.path,
3108
+ line: link.line
3109
+ });
3110
+ }
3111
+ }
3112
+ async function upsertNote(index, vaultPath2, notePath) {
3113
+ try {
3114
+ const existed = index.notes.has(notePath);
3115
+ if (existed) {
3116
+ removeNoteFromIndex(index, notePath);
3117
+ }
3118
+ const fullPath = path6.join(vaultPath2, notePath);
3119
+ const fs31 = await import("fs/promises");
3120
+ const stats = await fs31.stat(fullPath);
3121
+ const vaultFile = {
3122
+ path: notePath,
3123
+ absolutePath: fullPath,
3124
+ modified: stats.mtime
3125
+ };
3126
+ const note = await parseNote(vaultFile);
3127
+ addNoteToIndex(index, note);
3128
+ return {
3129
+ success: true,
3130
+ action: existed ? "updated" : "added",
3131
+ path: notePath
3132
+ };
3133
+ } catch (error) {
3134
+ return {
3135
+ success: false,
3136
+ action: "unchanged",
3137
+ path: notePath,
3138
+ error: error instanceof Error ? error : new Error(String(error))
3139
+ };
3140
+ }
3141
+ }
3142
+ function deleteNote(index, notePath) {
3143
+ const removed = removeNoteFromIndex(index, notePath);
3144
+ return {
3145
+ success: removed,
3146
+ action: removed ? "removed" : "unchanged",
3147
+ path: notePath
3148
+ };
3149
+ }
3150
+
3151
+ // src/core/read/watch/batchProcessor.ts
3152
+ var DEFAULT_CONCURRENCY = 4;
3153
+ var YIELD_INTERVAL = 10;
3154
+ async function processBatch(index, vaultPath2, batch, options = {}) {
3155
+ const { concurrency = DEFAULT_CONCURRENCY, onProgress, onError } = options;
3156
+ const startTime = Date.now();
3157
+ const results = [];
3158
+ let successful = 0;
3159
+ let failed = 0;
3160
+ let processed = 0;
3161
+ const events = batch.events;
3162
+ const total = events.length;
3163
+ if (total === 0) {
3164
+ return {
3165
+ total: 0,
3166
+ successful: 0,
3167
+ failed: 0,
3168
+ results: [],
3169
+ durationMs: 0
3170
+ };
3171
+ }
3172
+ console.error(`[flywheel] Processing ${total} file events`);
3173
+ for (let i = 0; i < events.length; i += concurrency) {
3174
+ const chunk = events.slice(i, i + concurrency);
3175
+ const chunkResults = await Promise.allSettled(
3176
+ chunk.map(async (event) => {
3177
+ const relativePath = getRelativePath(vaultPath2, event.path);
3178
+ if (event.type === "delete") {
3179
+ return deleteNote(index, relativePath);
3180
+ } else {
3181
+ return upsertNote(index, vaultPath2, relativePath);
3182
+ }
3183
+ })
3184
+ );
3185
+ for (let j = 0; j < chunkResults.length; j++) {
3186
+ const result = chunkResults[j];
3187
+ processed++;
3188
+ if (result.status === "fulfilled") {
3189
+ results.push(result.value);
3190
+ if (result.value.success) {
3191
+ successful++;
3192
+ } else {
3193
+ failed++;
3194
+ if (result.value.error && onError) {
3195
+ onError(result.value.path, result.value.error);
3196
+ }
3197
+ }
3198
+ } else {
3199
+ failed++;
3200
+ const event = chunk[j];
3201
+ const relativePath = getRelativePath(vaultPath2, event.path);
3202
+ const error = result.reason instanceof Error ? result.reason : new Error(String(result.reason));
3203
+ results.push({
3204
+ success: false,
3205
+ action: "unchanged",
3206
+ path: relativePath,
3207
+ error
3208
+ });
3209
+ onError?.(relativePath, error);
3210
+ }
3211
+ }
3212
+ onProgress?.(processed, total);
3213
+ if (processed % YIELD_INTERVAL === 0 && processed < total) {
3214
+ await new Promise((resolve2) => setImmediate(resolve2));
3215
+ }
3216
+ }
3217
+ const durationMs = Date.now() - startTime;
3218
+ console.error(`[flywheel] Processed ${successful}/${total} files in ${durationMs}ms`);
3219
+ return {
3220
+ total,
3221
+ successful,
3222
+ failed,
3223
+ results,
3224
+ durationMs
3225
+ };
3226
+ }
3227
+
3016
3228
  // src/core/read/watch/index.ts
3017
3229
  function createVaultWatcher(options) {
3018
3230
  const { vaultPath: vaultPath2, onBatch, onStateChange, onError } = options;
@@ -3087,31 +3299,31 @@ function createVaultWatcher(options) {
3087
3299
  usePolling: config.usePolling,
3088
3300
  interval: config.usePolling ? config.pollInterval : void 0
3089
3301
  });
3090
- watcher.on("add", (path30) => {
3091
- console.error(`[flywheel] RAW EVENT: add ${path30}`);
3092
- if (shouldWatch(path30, vaultPath2)) {
3093
- console.error(`[flywheel] ACCEPTED: add ${path30}`);
3094
- eventQueue.push("add", path30);
3302
+ watcher.on("add", (path31) => {
3303
+ console.error(`[flywheel] RAW EVENT: add ${path31}`);
3304
+ if (shouldWatch(path31, vaultPath2)) {
3305
+ console.error(`[flywheel] ACCEPTED: add ${path31}`);
3306
+ eventQueue.push("add", path31);
3095
3307
  } else {
3096
- console.error(`[flywheel] FILTERED: add ${path30}`);
3308
+ console.error(`[flywheel] FILTERED: add ${path31}`);
3097
3309
  }
3098
3310
  });
3099
- watcher.on("change", (path30) => {
3100
- console.error(`[flywheel] RAW EVENT: change ${path30}`);
3101
- if (shouldWatch(path30, vaultPath2)) {
3102
- console.error(`[flywheel] ACCEPTED: change ${path30}`);
3103
- eventQueue.push("change", path30);
3311
+ watcher.on("change", (path31) => {
3312
+ console.error(`[flywheel] RAW EVENT: change ${path31}`);
3313
+ if (shouldWatch(path31, vaultPath2)) {
3314
+ console.error(`[flywheel] ACCEPTED: change ${path31}`);
3315
+ eventQueue.push("change", path31);
3104
3316
  } else {
3105
- console.error(`[flywheel] FILTERED: change ${path30}`);
3317
+ console.error(`[flywheel] FILTERED: change ${path31}`);
3106
3318
  }
3107
3319
  });
3108
- watcher.on("unlink", (path30) => {
3109
- console.error(`[flywheel] RAW EVENT: unlink ${path30}`);
3110
- if (shouldWatch(path30, vaultPath2)) {
3111
- console.error(`[flywheel] ACCEPTED: unlink ${path30}`);
3112
- eventQueue.push("unlink", path30);
3320
+ watcher.on("unlink", (path31) => {
3321
+ console.error(`[flywheel] RAW EVENT: unlink ${path31}`);
3322
+ if (shouldWatch(path31, vaultPath2)) {
3323
+ console.error(`[flywheel] ACCEPTED: unlink ${path31}`);
3324
+ eventQueue.push("unlink", path31);
3113
3325
  } else {
3114
- console.error(`[flywheel] FILTERED: unlink ${path30}`);
3326
+ console.error(`[flywheel] FILTERED: unlink ${path31}`);
3115
3327
  }
3116
3328
  });
3117
3329
  watcher.on("ready", () => {
@@ -3241,9 +3453,14 @@ var FEEDBACK_BOOST_TIERS = [
3241
3453
  { minAccuracy: 0, minSamples: 5, boost: -4 }
3242
3454
  ];
3243
3455
  function recordFeedback(stateDb2, entity, context, notePath, correct) {
3244
- stateDb2.db.prepare(
3245
- "INSERT INTO wikilink_feedback (entity, context, note_path, correct) VALUES (?, ?, ?, ?)"
3246
- ).run(entity, context, notePath, correct ? 1 : 0);
3456
+ try {
3457
+ stateDb2.db.prepare(
3458
+ "INSERT INTO wikilink_feedback (entity, context, note_path, correct) VALUES (?, ?, ?, ?)"
3459
+ ).run(entity, context, notePath, correct ? 1 : 0);
3460
+ } catch (e) {
3461
+ console.error(`[Flywheel] recordFeedback failed for entity="${entity}": ${e}`);
3462
+ throw e;
3463
+ }
3247
3464
  }
3248
3465
  function getFeedback(stateDb2, entity, limit = 20) {
3249
3466
  let rows;
@@ -3554,10 +3771,163 @@ function getDashboardData(stateDb2) {
3554
3771
  }))
3555
3772
  };
3556
3773
  }
3774
+ function getEntityScoreTimeline(stateDb2, entityName, daysBack = 30, limit = 100) {
3775
+ const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1e3;
3776
+ const rows = stateDb2.db.prepare(`
3777
+ SELECT timestamp, total_score, breakdown_json, note_path, passed, threshold
3778
+ FROM suggestion_events
3779
+ WHERE entity = ? AND timestamp >= ?
3780
+ ORDER BY timestamp ASC
3781
+ LIMIT ?
3782
+ `).all(entityName, cutoff, limit);
3783
+ return rows.map((r) => ({
3784
+ timestamp: r.timestamp,
3785
+ score: r.total_score,
3786
+ breakdown: JSON.parse(r.breakdown_json),
3787
+ notePath: r.note_path,
3788
+ passed: r.passed === 1,
3789
+ threshold: r.threshold
3790
+ }));
3791
+ }
3792
+ function getLayerContributionTimeseries(stateDb2, granularity = "day", daysBack = 30) {
3793
+ const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1e3;
3794
+ const rows = stateDb2.db.prepare(`
3795
+ SELECT timestamp, breakdown_json
3796
+ FROM suggestion_events
3797
+ WHERE timestamp >= ?
3798
+ ORDER BY timestamp ASC
3799
+ `).all(cutoff);
3800
+ const buckets = /* @__PURE__ */ new Map();
3801
+ for (const row of rows) {
3802
+ const date = new Date(row.timestamp);
3803
+ let bucket;
3804
+ if (granularity === "week") {
3805
+ const jan4 = new Date(date.getFullYear(), 0, 4);
3806
+ const weekNum = Math.ceil(((date.getTime() - jan4.getTime()) / 864e5 + jan4.getDay() + 1) / 7);
3807
+ bucket = `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`;
3808
+ } else {
3809
+ bucket = date.toISOString().slice(0, 10);
3810
+ }
3811
+ if (!buckets.has(bucket)) {
3812
+ buckets.set(bucket, { count: 0, layers: {} });
3813
+ }
3814
+ const acc = buckets.get(bucket);
3815
+ acc.count++;
3816
+ const breakdown = JSON.parse(row.breakdown_json);
3817
+ const layerMap = {
3818
+ contentMatch: breakdown.contentMatch,
3819
+ cooccurrenceBoost: breakdown.cooccurrenceBoost,
3820
+ typeBoost: breakdown.typeBoost,
3821
+ contextBoost: breakdown.contextBoost,
3822
+ recencyBoost: breakdown.recencyBoost,
3823
+ crossFolderBoost: breakdown.crossFolderBoost,
3824
+ hubBoost: breakdown.hubBoost,
3825
+ feedbackAdjustment: breakdown.feedbackAdjustment
3826
+ };
3827
+ if (breakdown.semanticBoost !== void 0) {
3828
+ layerMap.semanticBoost = breakdown.semanticBoost;
3829
+ }
3830
+ for (const [layer, value] of Object.entries(layerMap)) {
3831
+ acc.layers[layer] = (acc.layers[layer] ?? 0) + value;
3832
+ }
3833
+ }
3834
+ const result = [];
3835
+ for (const [bucket, acc] of buckets) {
3836
+ const avgLayers = {};
3837
+ for (const [layer, sum] of Object.entries(acc.layers)) {
3838
+ avgLayers[layer] = Math.round(sum / acc.count * 1e3) / 1e3;
3839
+ }
3840
+ result.push({ bucket, layers: avgLayers });
3841
+ }
3842
+ return result;
3843
+ }
3844
+ function getExtendedDashboardData(stateDb2) {
3845
+ const base = getDashboardData(stateDb2);
3846
+ const recentCutoff = Date.now() - 7 * 24 * 60 * 60 * 1e3;
3847
+ const eventRows = stateDb2.db.prepare(`
3848
+ SELECT breakdown_json FROM suggestion_events WHERE timestamp >= ?
3849
+ `).all(recentCutoff);
3850
+ const layerSums = {};
3851
+ const LAYER_NAMES = [
3852
+ "contentMatch",
3853
+ "cooccurrenceBoost",
3854
+ "typeBoost",
3855
+ "contextBoost",
3856
+ "recencyBoost",
3857
+ "crossFolderBoost",
3858
+ "hubBoost",
3859
+ "feedbackAdjustment",
3860
+ "semanticBoost"
3861
+ ];
3862
+ for (const name of LAYER_NAMES) {
3863
+ layerSums[name] = { sum: 0, count: 0 };
3864
+ }
3865
+ for (const row of eventRows) {
3866
+ const breakdown = JSON.parse(row.breakdown_json);
3867
+ for (const name of LAYER_NAMES) {
3868
+ const val = breakdown[name];
3869
+ if (val !== void 0) {
3870
+ layerSums[name].sum += Math.abs(val);
3871
+ layerSums[name].count++;
3872
+ }
3873
+ }
3874
+ }
3875
+ const layerHealth = LAYER_NAMES.map((layer) => {
3876
+ const s = layerSums[layer];
3877
+ const avg = s.count > 0 ? Math.round(s.sum / s.count * 1e3) / 1e3 : 0;
3878
+ let status;
3879
+ if (s.count === 0) status = "zero-data";
3880
+ else if (avg > 0) status = "contributing";
3881
+ else status = "dormant";
3882
+ return { layer, status, avgContribution: avg, eventCount: s.count };
3883
+ });
3884
+ const topEntityRows = stateDb2.db.prepare(`
3885
+ SELECT entity, COUNT(*) as cnt, AVG(total_score) as avg_score,
3886
+ SUM(CASE WHEN passed = 1 THEN 1 ELSE 0 END) * 1.0 / COUNT(*) as pass_rate
3887
+ FROM suggestion_events
3888
+ GROUP BY entity
3889
+ ORDER BY cnt DESC
3890
+ LIMIT 10
3891
+ `).all();
3892
+ const topEntities = topEntityRows.map((r) => ({
3893
+ entity: r.entity,
3894
+ suggestionCount: r.cnt,
3895
+ avgScore: Math.round(r.avg_score * 100) / 100,
3896
+ passRate: Math.round(r.pass_rate * 1e3) / 1e3
3897
+ }));
3898
+ const feedbackTrendRows = stateDb2.db.prepare(`
3899
+ SELECT date(created_at) as day, COUNT(*) as count
3900
+ FROM wikilink_feedback
3901
+ WHERE created_at >= datetime('now', '-30 days')
3902
+ GROUP BY day
3903
+ ORDER BY day
3904
+ `).all();
3905
+ const feedbackTrend = feedbackTrendRows.map((r) => ({
3906
+ day: r.day,
3907
+ count: r.count
3908
+ }));
3909
+ const suppressionRows = stateDb2.db.prepare(`
3910
+ SELECT entity, false_positive_rate, updated_at
3911
+ FROM wikilink_suppressions
3912
+ ORDER BY updated_at DESC
3913
+ `).all();
3914
+ const suppressionChanges = suppressionRows.map((r) => ({
3915
+ entity: r.entity,
3916
+ falsePositiveRate: r.false_positive_rate,
3917
+ updatedAt: r.updated_at
3918
+ }));
3919
+ return {
3920
+ ...base,
3921
+ layerHealth,
3922
+ topEntities,
3923
+ feedbackTrend,
3924
+ suppressionChanges
3925
+ };
3926
+ }
3557
3927
 
3558
3928
  // src/core/write/git.ts
3559
3929
  import { simpleGit, CheckRepoActions } from "simple-git";
3560
- import path6 from "path";
3930
+ import path7 from "path";
3561
3931
  import fs6 from "fs/promises";
3562
3932
  import {
3563
3933
  setWriteState,
@@ -3611,7 +3981,7 @@ function clearLastMutationCommit() {
3611
3981
  }
3612
3982
  }
3613
3983
  async function checkGitLock(vaultPath2) {
3614
- const lockPath = path6.join(vaultPath2, ".git/index.lock");
3984
+ const lockPath = path7.join(vaultPath2, ".git/index.lock");
3615
3985
  try {
3616
3986
  const stat3 = await fs6.stat(lockPath);
3617
3987
  const ageMs = Date.now() - stat3.mtimeMs;
@@ -3634,7 +4004,7 @@ async function isGitRepo(vaultPath2) {
3634
4004
  }
3635
4005
  }
3636
4006
  async function checkLockFile(vaultPath2) {
3637
- const lockPath = path6.join(vaultPath2, ".git/index.lock");
4007
+ const lockPath = path7.join(vaultPath2, ".git/index.lock");
3638
4008
  try {
3639
4009
  const stat3 = await fs6.stat(lockPath);
3640
4010
  const ageMs = Date.now() - stat3.mtimeMs;
@@ -3684,7 +4054,7 @@ async function commitChange(vaultPath2, filePath, messagePrefix, retryConfig = D
3684
4054
  }
3685
4055
  }
3686
4056
  await git.add(filePath);
3687
- const fileName = path6.basename(filePath);
4057
+ const fileName = path7.basename(filePath);
3688
4058
  const commitMessage = `${messagePrefix} Update ${fileName}`;
3689
4059
  const result = await git.commit(commitMessage);
3690
4060
  if (result.commit) {
@@ -3878,7 +4248,7 @@ function setHintsStateDb(stateDb2) {
3878
4248
 
3879
4249
  // src/core/shared/recency.ts
3880
4250
  import { readdir, readFile, stat } from "fs/promises";
3881
- import path7 from "path";
4251
+ import path8 from "path";
3882
4252
  import {
3883
4253
  getEntityName,
3884
4254
  recordEntityMention,
@@ -3900,9 +4270,9 @@ async function* walkMarkdownFiles(dir, baseDir) {
3900
4270
  try {
3901
4271
  const entries = await readdir(dir, { withFileTypes: true });
3902
4272
  for (const entry of entries) {
3903
- const fullPath = path7.join(dir, entry.name);
3904
- const relativePath = path7.relative(baseDir, fullPath);
3905
- const topFolder = relativePath.split(path7.sep)[0];
4273
+ const fullPath = path8.join(dir, entry.name);
4274
+ const relativePath = path8.relative(baseDir, fullPath);
4275
+ const topFolder = relativePath.split(path8.sep)[0];
3906
4276
  if (EXCLUDED_FOLDERS.has(topFolder)) {
3907
4277
  continue;
3908
4278
  }
@@ -3986,14 +4356,16 @@ function loadRecencyFromStateDb() {
3986
4356
  }
3987
4357
  function saveRecencyToStateDb(index) {
3988
4358
  if (!moduleStateDb3) {
3989
- console.error("[Flywheel] No StateDb available for saving recency");
4359
+ console.error("[Flywheel] saveRecencyToStateDb: No StateDb available (moduleStateDb is null)");
3990
4360
  return;
3991
4361
  }
4362
+ console.error(`[Flywheel] saveRecencyToStateDb: Saving ${index.lastMentioned.size} entries...`);
3992
4363
  try {
3993
4364
  for (const [entityNameLower, timestamp] of index.lastMentioned) {
3994
4365
  recordEntityMention(moduleStateDb3, entityNameLower, new Date(timestamp));
3995
4366
  }
3996
- console.error(`[Flywheel] Saved ${index.lastMentioned.size} recency entries to StateDb`);
4367
+ const count = moduleStateDb3.db.prepare("SELECT COUNT(*) as cnt FROM recency").get();
4368
+ console.error(`[Flywheel] Saved recency: ${index.lastMentioned.size} entries \u2192 ${count.cnt} rows in table`);
3997
4369
  } catch (e) {
3998
4370
  console.error("[Flywheel] Failed to save recency to StateDb:", e);
3999
4371
  }
@@ -4780,7 +5152,7 @@ function tokenize(text) {
4780
5152
 
4781
5153
  // src/core/shared/cooccurrence.ts
4782
5154
  import { readdir as readdir2, readFile as readFile2 } from "fs/promises";
4783
- import path8 from "path";
5155
+ import path9 from "path";
4784
5156
  var DEFAULT_MIN_COOCCURRENCE = 2;
4785
5157
  var EXCLUDED_FOLDERS2 = /* @__PURE__ */ new Set([
4786
5158
  "templates",
@@ -4814,9 +5186,9 @@ async function* walkMarkdownFiles2(dir, baseDir) {
4814
5186
  try {
4815
5187
  const entries = await readdir2(dir, { withFileTypes: true });
4816
5188
  for (const entry of entries) {
4817
- const fullPath = path8.join(dir, entry.name);
4818
- const relativePath = path8.relative(baseDir, fullPath);
4819
- const topFolder = relativePath.split(path8.sep)[0];
5189
+ const fullPath = path9.join(dir, entry.name);
5190
+ const relativePath = path9.relative(baseDir, fullPath);
5191
+ const topFolder = relativePath.split(path9.sep)[0];
4820
5192
  if (EXCLUDED_FOLDERS2.has(topFolder)) {
4821
5193
  continue;
4822
5194
  }
@@ -5079,12 +5451,15 @@ function processWikilinks(content, notePath) {
5079
5451
  const resolved = resolveAliasWikilinks(content, sortedEntities, {
5080
5452
  caseInsensitive: true
5081
5453
  });
5454
+ const step1LinkedEntities = new Set(resolved.linkedEntities.map((e) => e.toLowerCase()));
5082
5455
  const result = applyWikilinks(resolved.content, sortedEntities, {
5083
5456
  firstOccurrenceOnly: true,
5084
- caseInsensitive: true
5457
+ caseInsensitive: true,
5458
+ alreadyLinked: step1LinkedEntities
5085
5459
  });
5086
5460
  const implicitEnabled = moduleConfig?.implicit_detection !== false;
5087
- const implicitPatterns = moduleConfig?.implicit_patterns?.length ? moduleConfig.implicit_patterns : [...ALL_IMPLICIT_PATTERNS];
5461
+ const validPatterns = new Set(ALL_IMPLICIT_PATTERNS);
5462
+ const implicitPatterns = moduleConfig?.implicit_patterns?.length ? moduleConfig.implicit_patterns.filter((p) => validPatterns.has(p)) : [...ALL_IMPLICIT_PATTERNS];
5088
5463
  const implicitMatches = detectImplicitEntities(result.content, {
5089
5464
  detectImplicit: implicitEnabled,
5090
5465
  implicitPatterns,
@@ -5505,8 +5880,10 @@ async function suggestRelatedLinks(content, options = {}) {
5505
5880
  excludeLinked = true,
5506
5881
  strictness = getEffectiveStrictness(options.notePath),
5507
5882
  notePath,
5508
- detail = false
5883
+ detail = false,
5884
+ disabledLayers = []
5509
5885
  } = options;
5886
+ const disabled = new Set(disabledLayers);
5510
5887
  const config = STRICTNESS_CONFIGS[strictness];
5511
5888
  const adaptiveMinScore = getAdaptiveMinScore(content.length, config.minSuggestionScore);
5512
5889
  const noteContext = notePath ? getNoteContext(notePath) : "general";
@@ -5547,31 +5924,31 @@ async function suggestRelatedLinks(content, options = {}) {
5547
5924
  for (const { entity, category } of entitiesWithTypes) {
5548
5925
  const entityName = entity.name;
5549
5926
  if (!entityName) continue;
5550
- if (entityName.length > MAX_ENTITY_LENGTH) {
5927
+ if (!disabled.has("length_filter") && entityName.length > MAX_ENTITY_LENGTH) {
5551
5928
  continue;
5552
5929
  }
5553
- if (isLikelyArticleTitle(entityName)) {
5930
+ if (!disabled.has("article_filter") && isLikelyArticleTitle(entityName)) {
5554
5931
  continue;
5555
5932
  }
5556
5933
  if (linkedEntities.has(entityName.toLowerCase())) {
5557
5934
  continue;
5558
5935
  }
5559
- const contentScore = scoreEntity(entity, contentTokens, contentStems, config);
5936
+ const contentScore = disabled.has("exact_match") && disabled.has("stem_match") ? 0 : scoreEntity(entity, contentTokens, contentStems, config);
5560
5937
  let score = contentScore;
5561
5938
  if (contentScore > 0) {
5562
5939
  entitiesWithContentMatch.add(entityName);
5563
5940
  }
5564
- const layerTypeBoost = TYPE_BOOST[category] || 0;
5941
+ const layerTypeBoost = disabled.has("type_boost") ? 0 : TYPE_BOOST[category] || 0;
5565
5942
  score += layerTypeBoost;
5566
- const layerContextBoost = contextBoosts[category] || 0;
5943
+ const layerContextBoost = disabled.has("context_boost") ? 0 : contextBoosts[category] || 0;
5567
5944
  score += layerContextBoost;
5568
- const layerRecencyBoost = recencyIndex ? getRecencyBoost(entityName, recencyIndex) : 0;
5945
+ const layerRecencyBoost = disabled.has("recency") ? 0 : recencyIndex ? getRecencyBoost(entityName, recencyIndex) : 0;
5569
5946
  score += layerRecencyBoost;
5570
- const layerCrossFolderBoost = notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
5947
+ const layerCrossFolderBoost = disabled.has("cross_folder") ? 0 : notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
5571
5948
  score += layerCrossFolderBoost;
5572
- const layerHubBoost = getHubBoost(entity);
5949
+ const layerHubBoost = disabled.has("hub_boost") ? 0 : getHubBoost(entity);
5573
5950
  score += layerHubBoost;
5574
- const layerFeedbackAdj = feedbackBoosts.get(entityName) ?? 0;
5951
+ const layerFeedbackAdj = disabled.has("feedback") ? 0 : feedbackBoosts.get(entityName) ?? 0;
5575
5952
  score += layerFeedbackAdj;
5576
5953
  if (score > 0) {
5577
5954
  directlyMatchedEntities.add(entityName);
@@ -5595,12 +5972,12 @@ async function suggestRelatedLinks(content, options = {}) {
5595
5972
  });
5596
5973
  }
5597
5974
  }
5598
- if (cooccurrenceIndex && directlyMatchedEntities.size > 0) {
5975
+ if (!disabled.has("cooccurrence") && cooccurrenceIndex && directlyMatchedEntities.size > 0) {
5599
5976
  for (const { entity, category } of entitiesWithTypes) {
5600
5977
  const entityName = entity.name;
5601
5978
  if (!entityName) continue;
5602
- if (entityName.length > MAX_ENTITY_LENGTH) continue;
5603
- if (isLikelyArticleTitle(entityName)) continue;
5979
+ if (!disabled.has("length_filter") && entityName.length > MAX_ENTITY_LENGTH) continue;
5980
+ if (!disabled.has("article_filter") && isLikelyArticleTitle(entityName)) continue;
5604
5981
  if (linkedEntities.has(entityName.toLowerCase())) continue;
5605
5982
  const boost = getCooccurrenceBoost(entityName, directlyMatchedEntities, cooccurrenceIndex, recencyIndex);
5606
5983
  if (boost > 0) {
@@ -5617,12 +5994,12 @@ async function suggestRelatedLinks(content, options = {}) {
5617
5994
  continue;
5618
5995
  }
5619
5996
  entitiesWithContentMatch.add(entityName);
5620
- const typeBoost = TYPE_BOOST[category] || 0;
5621
- const contextBoost = contextBoosts[category] || 0;
5622
- const recencyBoostVal = recencyIndex ? getRecencyBoost(entityName, recencyIndex) : 0;
5623
- const crossFolderBoost = notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
5624
- const hubBoost = getHubBoost(entity);
5625
- const feedbackAdj = feedbackBoosts.get(entityName) ?? 0;
5997
+ const typeBoost = disabled.has("type_boost") ? 0 : TYPE_BOOST[category] || 0;
5998
+ const contextBoost = disabled.has("context_boost") ? 0 : contextBoosts[category] || 0;
5999
+ const recencyBoostVal = disabled.has("recency") ? 0 : recencyIndex ? getRecencyBoost(entityName, recencyIndex) : 0;
6000
+ const crossFolderBoost = disabled.has("cross_folder") ? 0 : notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
6001
+ const hubBoost = disabled.has("hub_boost") ? 0 : getHubBoost(entity);
6002
+ const feedbackAdj = disabled.has("feedback") ? 0 : feedbackBoosts.get(entityName) ?? 0;
5626
6003
  const totalBoost = boost + typeBoost + contextBoost + recencyBoostVal + crossFolderBoost + hubBoost + feedbackAdj;
5627
6004
  if (totalBoost >= adaptiveMinScore) {
5628
6005
  scoredEntities.push({
@@ -5646,7 +6023,7 @@ async function suggestRelatedLinks(content, options = {}) {
5646
6023
  }
5647
6024
  }
5648
6025
  }
5649
- if (content.length >= 20 && hasEntityEmbeddingsIndex()) {
6026
+ if (!disabled.has("semantic") && content.length >= 20 && hasEntityEmbeddingsIndex()) {
5650
6027
  try {
5651
6028
  const contentEmbedding = await embedTextCached(content);
5652
6029
  const alreadyScoredNames = new Set(scoredEntities.map((e) => e.name));
@@ -5668,14 +6045,14 @@ async function suggestRelatedLinks(content, options = {}) {
5668
6045
  (et) => et.entity.name === match.entityName
5669
6046
  );
5670
6047
  if (!entityWithType) continue;
5671
- if (match.entityName.length > MAX_ENTITY_LENGTH) continue;
5672
- if (isLikelyArticleTitle(match.entityName)) continue;
6048
+ if (!disabled.has("length_filter") && match.entityName.length > MAX_ENTITY_LENGTH) continue;
6049
+ if (!disabled.has("article_filter") && isLikelyArticleTitle(match.entityName)) continue;
5673
6050
  const { entity, category } = entityWithType;
5674
- const layerTypeBoost = TYPE_BOOST[category] || 0;
5675
- const layerContextBoost = contextBoosts[category] || 0;
5676
- const layerHubBoost = getHubBoost(entity);
5677
- const layerCrossFolderBoost = notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
5678
- const layerFeedbackAdj = feedbackBoosts.get(match.entityName) ?? 0;
6051
+ const layerTypeBoost = disabled.has("type_boost") ? 0 : TYPE_BOOST[category] || 0;
6052
+ const layerContextBoost = disabled.has("context_boost") ? 0 : contextBoosts[category] || 0;
6053
+ const layerHubBoost = disabled.has("hub_boost") ? 0 : getHubBoost(entity);
6054
+ const layerCrossFolderBoost = disabled.has("cross_folder") ? 0 : notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
6055
+ const layerFeedbackAdj = disabled.has("feedback") ? 0 : feedbackBoosts.get(match.entityName) ?? 0;
5679
6056
  const totalScore = boost + layerTypeBoost + layerContextBoost + layerHubBoost + layerCrossFolderBoost + layerFeedbackAdj;
5680
6057
  if (totalScore >= adaptiveMinScore) {
5681
6058
  scoredEntities.push({
@@ -5717,7 +6094,51 @@ async function suggestRelatedLinks(content, options = {}) {
5717
6094
  }
5718
6095
  return 0;
5719
6096
  });
5720
- const topEntries = relevantEntities.slice(0, maxSuggestions);
6097
+ if (moduleStateDb4 && notePath) {
6098
+ try {
6099
+ const now = Date.now();
6100
+ const insertStmt = moduleStateDb4.db.prepare(`
6101
+ INSERT OR IGNORE INTO suggestion_events
6102
+ (timestamp, note_path, entity, total_score, breakdown_json, threshold, passed, strictness, applied, pipeline_event_id)
6103
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, NULL)
6104
+ `);
6105
+ const persistTransaction = moduleStateDb4.db.transaction(() => {
6106
+ for (const e of relevantEntities) {
6107
+ insertStmt.run(
6108
+ now,
6109
+ notePath,
6110
+ e.name,
6111
+ e.score,
6112
+ JSON.stringify(e.breakdown),
6113
+ adaptiveMinScore,
6114
+ 1,
6115
+ // passed threshold (these are relevantEntities)
6116
+ strictness
6117
+ );
6118
+ }
6119
+ for (const e of scoredEntities) {
6120
+ if (!entitiesWithContentMatch.has(e.name)) continue;
6121
+ if (relevantEntities.some((r) => r.name === e.name)) continue;
6122
+ insertStmt.run(
6123
+ now,
6124
+ notePath,
6125
+ e.name,
6126
+ e.score,
6127
+ JSON.stringify(e.breakdown),
6128
+ adaptiveMinScore,
6129
+ 0,
6130
+ // did not pass threshold
6131
+ strictness
6132
+ );
6133
+ }
6134
+ });
6135
+ persistTransaction();
6136
+ } catch {
6137
+ }
6138
+ }
6139
+ const currentNoteStem = notePath ? notePath.replace(/\.md$/, "").split("/").pop()?.toLowerCase() : null;
6140
+ const filtered = currentNoteStem ? relevantEntities.filter((e) => e.name.toLowerCase() !== currentNoteStem) : relevantEntities;
6141
+ const topEntries = filtered.slice(0, maxSuggestions);
5721
6142
  const topSuggestions = topEntries.map((e) => e.name);
5722
6143
  if (topSuggestions.length === 0) {
5723
6144
  return emptyResult;
@@ -6081,11 +6502,11 @@ function countFTS5Mentions(term) {
6081
6502
  }
6082
6503
 
6083
6504
  // src/core/read/taskCache.ts
6084
- import * as path10 from "path";
6505
+ import * as path11 from "path";
6085
6506
 
6086
6507
  // src/tools/read/tasks.ts
6087
6508
  import * as fs8 from "fs";
6088
- import * as path9 from "path";
6509
+ import * as path10 from "path";
6089
6510
  var TASK_REGEX = /^(\s*)- \[([ xX\-])\]\s+(.+)$/;
6090
6511
  var TAG_REGEX2 = /#([a-zA-Z][a-zA-Z0-9_/-]*)/g;
6091
6512
  var DATE_REGEX = /\b(\d{4}-\d{2}-\d{2}|\d{1,2}\/\d{1,2}\/\d{2,4})\b/;
@@ -6154,7 +6575,7 @@ async function getAllTasks(index, vaultPath2, options = {}) {
6154
6575
  const allTasks = [];
6155
6576
  for (const note of index.notes.values()) {
6156
6577
  if (folder && !note.path.startsWith(folder)) continue;
6157
- const absolutePath = path9.join(vaultPath2, note.path);
6578
+ const absolutePath = path10.join(vaultPath2, note.path);
6158
6579
  const tasks = await extractTasksFromNote(note.path, absolutePath);
6159
6580
  allTasks.push(...tasks);
6160
6581
  }
@@ -6198,7 +6619,7 @@ async function getAllTasks(index, vaultPath2, options = {}) {
6198
6619
  async function getTasksFromNote(index, notePath, vaultPath2, excludeTags = []) {
6199
6620
  const note = index.notes.get(notePath);
6200
6621
  if (!note) return null;
6201
- const absolutePath = path9.join(vaultPath2, notePath);
6622
+ const absolutePath = path10.join(vaultPath2, notePath);
6202
6623
  let tasks = await extractTasksFromNote(notePath, absolutePath);
6203
6624
  if (excludeTags.length > 0) {
6204
6625
  tasks = tasks.filter(
@@ -6290,7 +6711,7 @@ async function buildTaskCache(vaultPath2, index, excludeTags) {
6290
6711
  }
6291
6712
  const allRows = [];
6292
6713
  for (const notePath of notePaths) {
6293
- const absolutePath = path10.join(vaultPath2, notePath);
6714
+ const absolutePath = path11.join(vaultPath2, notePath);
6294
6715
  const tasks = await extractTasksFromNote(notePath, absolutePath);
6295
6716
  for (const task of tasks) {
6296
6717
  if (excludeTags?.length && excludeTags.some((t) => task.tags.includes(t))) {
@@ -6332,7 +6753,7 @@ async function buildTaskCache(vaultPath2, index, excludeTags) {
6332
6753
  async function updateTaskCacheForFile(vaultPath2, relativePath) {
6333
6754
  if (!db3) return;
6334
6755
  db3.prepare("DELETE FROM tasks WHERE path = ?").run(relativePath);
6335
- const absolutePath = path10.join(vaultPath2, relativePath);
6756
+ const absolutePath = path11.join(vaultPath2, relativePath);
6336
6757
  const tasks = await extractTasksFromNote(relativePath, absolutePath);
6337
6758
  if (tasks.length > 0) {
6338
6759
  const insertStmt = db3.prepare(`
@@ -6472,7 +6893,7 @@ import { openStateDb, scanVaultEntities as scanVaultEntities3, getSessionId, get
6472
6893
 
6473
6894
  // src/tools/read/graph.ts
6474
6895
  import * as fs9 from "fs";
6475
- import * as path11 from "path";
6896
+ import * as path12 from "path";
6476
6897
  import { z } from "zod";
6477
6898
 
6478
6899
  // src/core/read/constants.ts
@@ -6756,7 +7177,7 @@ function requireIndex() {
6756
7177
  // src/tools/read/graph.ts
6757
7178
  async function getContext(vaultPath2, sourcePath, line, contextLines = 1) {
6758
7179
  try {
6759
- const fullPath = path11.join(vaultPath2, sourcePath);
7180
+ const fullPath = path12.join(vaultPath2, sourcePath);
6760
7181
  const content = await fs9.promises.readFile(fullPath, "utf-8");
6761
7182
  const allLines = content.split("\n");
6762
7183
  let fmLines = 0;
@@ -7070,14 +7491,14 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
7070
7491
  };
7071
7492
  function findSimilarEntity2(target, entities) {
7072
7493
  const targetLower = target.toLowerCase();
7073
- for (const [name, path30] of entities) {
7494
+ for (const [name, path31] of entities) {
7074
7495
  if (name.startsWith(targetLower) || targetLower.startsWith(name)) {
7075
- return path30;
7496
+ return path31;
7076
7497
  }
7077
7498
  }
7078
- for (const [name, path30] of entities) {
7499
+ for (const [name, path31] of entities) {
7079
7500
  if (name.includes(targetLower) || targetLower.includes(name)) {
7080
- return path30;
7501
+ return path31;
7081
7502
  }
7082
7503
  }
7083
7504
  return void 0;
@@ -8054,8 +8475,8 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
8054
8475
  daily_counts: z3.record(z3.number())
8055
8476
  }).describe("Activity summary for the last 7 days")
8056
8477
  };
8057
- function isPeriodicNote2(path30) {
8058
- const filename = path30.split("/").pop() || "";
8478
+ function isPeriodicNote2(path31) {
8479
+ const filename = path31.split("/").pop() || "";
8059
8480
  const nameWithoutExt = filename.replace(/\.md$/, "");
8060
8481
  const patterns = [
8061
8482
  /^\d{4}-\d{2}-\d{2}$/,
@@ -8070,7 +8491,7 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
8070
8491
  // YYYY (yearly)
8071
8492
  ];
8072
8493
  const periodicFolders = ["daily", "weekly", "monthly", "quarterly", "yearly", "journal", "journals"];
8073
- const folder = path30.split("/")[0]?.toLowerCase() || "";
8494
+ const folder = path31.split("/")[0]?.toLowerCase() || "";
8074
8495
  return patterns.some((p) => p.test(nameWithoutExt)) || periodicFolders.includes(folder);
8075
8496
  }
8076
8497
  server2.registerTool(
@@ -8478,7 +8899,7 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
8478
8899
 
8479
8900
  // src/tools/read/system.ts
8480
8901
  import * as fs11 from "fs";
8481
- import * as path12 from "path";
8902
+ import * as path13 from "path";
8482
8903
  import { z as z5 } from "zod";
8483
8904
  import { scanVaultEntities as scanVaultEntities2, getEntityIndexFromDb as getEntityIndexFromDb2 } from "@velvetmonkey/vault-core";
8484
8905
 
@@ -8778,7 +9199,7 @@ function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfi
8778
9199
  continue;
8779
9200
  }
8780
9201
  try {
8781
- const fullPath = path12.join(vaultPath2, note.path);
9202
+ const fullPath = path13.join(vaultPath2, note.path);
8782
9203
  const content = await fs11.promises.readFile(fullPath, "utf-8");
8783
9204
  const lines = content.split("\n");
8784
9205
  for (let i = 0; i < lines.length; i++) {
@@ -8894,7 +9315,7 @@ function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfi
8894
9315
  let wordCount;
8895
9316
  if (include_word_count) {
8896
9317
  try {
8897
- const fullPath = path12.join(vaultPath2, resolvedPath);
9318
+ const fullPath = path13.join(vaultPath2, resolvedPath);
8898
9319
  const content = await fs11.promises.readFile(fullPath, "utf-8");
8899
9320
  wordCount = content.split(/\s+/).filter((w) => w.length > 0).length;
8900
9321
  } catch {
@@ -9125,7 +9546,7 @@ import { z as z6 } from "zod";
9125
9546
 
9126
9547
  // src/tools/read/structure.ts
9127
9548
  import * as fs12 from "fs";
9128
- import * as path13 from "path";
9549
+ import * as path14 from "path";
9129
9550
  var HEADING_REGEX2 = /^(#{1,6})\s+(.+)$/;
9130
9551
  function extractHeadings(content) {
9131
9552
  const lines = content.split("\n");
@@ -9179,7 +9600,7 @@ function buildSections(headings, totalLines) {
9179
9600
  async function getNoteStructure(index, notePath, vaultPath2) {
9180
9601
  const note = index.notes.get(notePath);
9181
9602
  if (!note) return null;
9182
- const absolutePath = path13.join(vaultPath2, notePath);
9603
+ const absolutePath = path14.join(vaultPath2, notePath);
9183
9604
  let content;
9184
9605
  try {
9185
9606
  content = await fs12.promises.readFile(absolutePath, "utf-8");
@@ -9202,7 +9623,7 @@ async function getNoteStructure(index, notePath, vaultPath2) {
9202
9623
  async function getSectionContent(index, notePath, headingText, vaultPath2, includeSubheadings = true) {
9203
9624
  const note = index.notes.get(notePath);
9204
9625
  if (!note) return null;
9205
- const absolutePath = path13.join(vaultPath2, notePath);
9626
+ const absolutePath = path14.join(vaultPath2, notePath);
9206
9627
  let content;
9207
9628
  try {
9208
9629
  content = await fs12.promises.readFile(absolutePath, "utf-8");
@@ -9244,7 +9665,7 @@ async function findSections(index, headingPattern, vaultPath2, folder) {
9244
9665
  const results = [];
9245
9666
  for (const note of index.notes.values()) {
9246
9667
  if (folder && !note.path.startsWith(folder)) continue;
9247
- const absolutePath = path13.join(vaultPath2, note.path);
9668
+ const absolutePath = path14.join(vaultPath2, note.path);
9248
9669
  let content;
9249
9670
  try {
9250
9671
  content = await fs12.promises.readFile(absolutePath, "utf-8");
@@ -9278,18 +9699,18 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
9278
9699
  include_content: z6.boolean().default(false).describe("Include the text content under each top-level section")
9279
9700
  }
9280
9701
  },
9281
- async ({ path: path30, include_content }) => {
9702
+ async ({ path: path31, include_content }) => {
9282
9703
  const index = getIndex();
9283
9704
  const vaultPath2 = getVaultPath();
9284
- const result = await getNoteStructure(index, path30, vaultPath2);
9705
+ const result = await getNoteStructure(index, path31, vaultPath2);
9285
9706
  if (!result) {
9286
9707
  return {
9287
- content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path30 }, null, 2) }]
9708
+ content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path31 }, null, 2) }]
9288
9709
  };
9289
9710
  }
9290
9711
  if (include_content) {
9291
9712
  for (const section of result.sections) {
9292
- const sectionResult = await getSectionContent(index, path30, section.heading.text, vaultPath2, true);
9713
+ const sectionResult = await getSectionContent(index, path31, section.heading.text, vaultPath2, true);
9293
9714
  if (sectionResult) {
9294
9715
  section.content = sectionResult.content;
9295
9716
  }
@@ -9311,15 +9732,15 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
9311
9732
  include_subheadings: z6.boolean().default(true).describe("Include content under subheadings")
9312
9733
  }
9313
9734
  },
9314
- async ({ path: path30, heading, include_subheadings }) => {
9735
+ async ({ path: path31, heading, include_subheadings }) => {
9315
9736
  const index = getIndex();
9316
9737
  const vaultPath2 = getVaultPath();
9317
- const result = await getSectionContent(index, path30, heading, vaultPath2, include_subheadings);
9738
+ const result = await getSectionContent(index, path31, heading, vaultPath2, include_subheadings);
9318
9739
  if (!result) {
9319
9740
  return {
9320
9741
  content: [{ type: "text", text: JSON.stringify({
9321
9742
  error: "Section not found",
9322
- path: path30,
9743
+ path: path31,
9323
9744
  heading
9324
9745
  }, null, 2) }]
9325
9746
  };
@@ -9373,16 +9794,16 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
9373
9794
  offset: z6.coerce.number().default(0).describe("Number of results to skip (for pagination)")
9374
9795
  }
9375
9796
  },
9376
- async ({ path: path30, status, has_due_date, folder, tag, limit: requestedLimit, offset }) => {
9797
+ async ({ path: path31, status, has_due_date, folder, tag, limit: requestedLimit, offset }) => {
9377
9798
  const limit = Math.min(requestedLimit ?? 25, MAX_LIMIT);
9378
9799
  const index = getIndex();
9379
9800
  const vaultPath2 = getVaultPath();
9380
9801
  const config = getConfig();
9381
- if (path30) {
9382
- const result2 = await getTasksFromNote(index, path30, vaultPath2, config.exclude_task_tags || []);
9802
+ if (path31) {
9803
+ const result2 = await getTasksFromNote(index, path31, vaultPath2, config.exclude_task_tags || []);
9383
9804
  if (!result2) {
9384
9805
  return {
9385
- content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path30 }, null, 2) }]
9806
+ content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path31 }, null, 2) }]
9386
9807
  };
9387
9808
  }
9388
9809
  let filtered = result2;
@@ -9392,7 +9813,7 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
9392
9813
  const paged2 = filtered.slice(offset, offset + limit);
9393
9814
  return {
9394
9815
  content: [{ type: "text", text: JSON.stringify({
9395
- path: path30,
9816
+ path: path31,
9396
9817
  total_count: filtered.length,
9397
9818
  returned_count: paged2.length,
9398
9819
  open: result2.filter((t) => t.status === "open").length,
@@ -9548,7 +9969,7 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
9548
9969
  // src/tools/read/migrations.ts
9549
9970
  import { z as z7 } from "zod";
9550
9971
  import * as fs13 from "fs/promises";
9551
- import * as path14 from "path";
9972
+ import * as path15 from "path";
9552
9973
  import matter2 from "gray-matter";
9553
9974
  function getNotesInFolder(index, folder) {
9554
9975
  const notes = [];
@@ -9561,7 +9982,7 @@ function getNotesInFolder(index, folder) {
9561
9982
  return notes;
9562
9983
  }
9563
9984
  async function readFileContent(notePath, vaultPath2) {
9564
- const fullPath = path14.join(vaultPath2, notePath);
9985
+ const fullPath = path15.join(vaultPath2, notePath);
9565
9986
  try {
9566
9987
  return await fs13.readFile(fullPath, "utf-8");
9567
9988
  } catch {
@@ -9569,7 +9990,7 @@ async function readFileContent(notePath, vaultPath2) {
9569
9990
  }
9570
9991
  }
9571
9992
  async function writeFileContent(notePath, vaultPath2, content) {
9572
- const fullPath = path14.join(vaultPath2, notePath);
9993
+ const fullPath = path15.join(vaultPath2, notePath);
9573
9994
  try {
9574
9995
  await fs13.writeFile(fullPath, content, "utf-8");
9575
9996
  return true;
@@ -9750,7 +10171,7 @@ function registerMigrationTools(server2, getIndex, getVaultPath) {
9750
10171
 
9751
10172
  // src/tools/read/graphAnalysis.ts
9752
10173
  import fs14 from "node:fs";
9753
- import path15 from "node:path";
10174
+ import path16 from "node:path";
9754
10175
  import { z as z8 } from "zod";
9755
10176
 
9756
10177
  // src/tools/read/schema.ts
@@ -10295,6 +10716,49 @@ function getEmergingHubs(stateDb2, daysBack = 30) {
10295
10716
  emerging.sort((a, b) => b.growth - a.growth);
10296
10717
  return emerging;
10297
10718
  }
10719
+ function compareGraphSnapshots(stateDb2, timestampBefore, timestampAfter) {
10720
+ const SCALAR_METRICS = ["avg_degree", "max_degree", "cluster_count", "largest_cluster_size"];
10721
+ function getSnapshotAt(ts) {
10722
+ const row = stateDb2.db.prepare(
10723
+ `SELECT DISTINCT timestamp FROM graph_snapshots WHERE timestamp <= ? ORDER BY timestamp DESC LIMIT 1`
10724
+ ).get(ts);
10725
+ if (!row) return null;
10726
+ const rows = stateDb2.db.prepare(
10727
+ `SELECT metric, value, details FROM graph_snapshots WHERE timestamp = ?`
10728
+ ).all(row.timestamp);
10729
+ return rows;
10730
+ }
10731
+ const beforeRows = getSnapshotAt(timestampBefore) ?? [];
10732
+ const afterRows = getSnapshotAt(timestampAfter) ?? [];
10733
+ const beforeMap = /* @__PURE__ */ new Map();
10734
+ const afterMap = /* @__PURE__ */ new Map();
10735
+ for (const r of beforeRows) beforeMap.set(r.metric, { value: r.value, details: r.details });
10736
+ for (const r of afterRows) afterMap.set(r.metric, { value: r.value, details: r.details });
10737
+ const metricChanges = SCALAR_METRICS.map((metric) => {
10738
+ const before = beforeMap.get(metric)?.value ?? 0;
10739
+ const after = afterMap.get(metric)?.value ?? 0;
10740
+ const delta = after - before;
10741
+ const deltaPercent = before !== 0 ? Math.round(delta / before * 1e4) / 100 : delta !== 0 ? 100 : 0;
10742
+ return { metric, before, after, delta, deltaPercent };
10743
+ });
10744
+ const beforeHubs = beforeMap.get("hub_scores_top10")?.details ? JSON.parse(beforeMap.get("hub_scores_top10").details) : [];
10745
+ const afterHubs = afterMap.get("hub_scores_top10")?.details ? JSON.parse(afterMap.get("hub_scores_top10").details) : [];
10746
+ const beforeHubMap = /* @__PURE__ */ new Map();
10747
+ for (const h of beforeHubs) beforeHubMap.set(h.entity, h.degree);
10748
+ const afterHubMap = /* @__PURE__ */ new Map();
10749
+ for (const h of afterHubs) afterHubMap.set(h.entity, h.degree);
10750
+ const allHubEntities = /* @__PURE__ */ new Set([...beforeHubMap.keys(), ...afterHubMap.keys()]);
10751
+ const hubScoreChanges = [];
10752
+ for (const entity of allHubEntities) {
10753
+ const before = beforeHubMap.get(entity) ?? 0;
10754
+ const after = afterHubMap.get(entity) ?? 0;
10755
+ if (before !== after) {
10756
+ hubScoreChanges.push({ entity, before, after, delta: after - before });
10757
+ }
10758
+ }
10759
+ hubScoreChanges.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
10760
+ return { metricChanges, hubScoreChanges };
10761
+ }
10298
10762
  function purgeOldSnapshots(stateDb2, retentionDays = 90) {
10299
10763
  const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1e3;
10300
10764
  const result = stateDb2.db.prepare(
@@ -10469,7 +10933,7 @@ function registerGraphAnalysisTools(server2, getIndex, getVaultPath, getStateDb,
10469
10933
  const scored = allNotes.map((note) => {
10470
10934
  let wordCount = 0;
10471
10935
  try {
10472
- const content = fs14.readFileSync(path15.join(vaultPath2, note.path), "utf-8");
10936
+ const content = fs14.readFileSync(path16.join(vaultPath2, note.path), "utf-8");
10473
10937
  const body = content.replace(/^---[\s\S]*?---\n?/, "");
10474
10938
  wordCount = body.split(/\s+/).filter((w) => w.length > 0).length;
10475
10939
  } catch {
@@ -11049,12 +11513,12 @@ import { z as z10 } from "zod";
11049
11513
 
11050
11514
  // src/tools/read/bidirectional.ts
11051
11515
  import * as fs15 from "fs/promises";
11052
- import * as path16 from "path";
11516
+ import * as path17 from "path";
11053
11517
  import matter3 from "gray-matter";
11054
11518
  var PROSE_PATTERN_REGEX = /^([A-Za-z][A-Za-z0-9 _-]*):\s*(?:\[\[([^\]]+)\]\]|"([^"]+)"|([^\n]+?))\s*$/gm;
11055
11519
  var CODE_BLOCK_REGEX2 = /```[\s\S]*?```|`[^`\n]+`/g;
11056
11520
  async function readFileContent2(notePath, vaultPath2) {
11057
- const fullPath = path16.join(vaultPath2, notePath);
11521
+ const fullPath = path17.join(vaultPath2, notePath);
11058
11522
  try {
11059
11523
  return await fs15.readFile(fullPath, "utf-8");
11060
11524
  } catch {
@@ -11233,10 +11697,10 @@ async function suggestWikilinksInFrontmatter(index, notePath, vaultPath2) {
11233
11697
 
11234
11698
  // src/tools/read/computed.ts
11235
11699
  import * as fs16 from "fs/promises";
11236
- import * as path17 from "path";
11700
+ import * as path18 from "path";
11237
11701
  import matter4 from "gray-matter";
11238
11702
  async function readFileContent3(notePath, vaultPath2) {
11239
- const fullPath = path17.join(vaultPath2, notePath);
11703
+ const fullPath = path18.join(vaultPath2, notePath);
11240
11704
  try {
11241
11705
  return await fs16.readFile(fullPath, "utf-8");
11242
11706
  } catch {
@@ -11244,7 +11708,7 @@ async function readFileContent3(notePath, vaultPath2) {
11244
11708
  }
11245
11709
  }
11246
11710
  async function getFileStats(notePath, vaultPath2) {
11247
- const fullPath = path17.join(vaultPath2, notePath);
11711
+ const fullPath = path18.join(vaultPath2, notePath);
11248
11712
  try {
11249
11713
  const stats = await fs16.stat(fullPath);
11250
11714
  return {
@@ -11515,7 +11979,7 @@ function registerNoteIntelligenceTools(server2, getIndex, getVaultPath, getConfi
11515
11979
  init_writer();
11516
11980
  import { z as z11 } from "zod";
11517
11981
  import fs20 from "fs/promises";
11518
- import path20 from "path";
11982
+ import path21 from "path";
11519
11983
 
11520
11984
  // src/core/write/validator.ts
11521
11985
  var TIMESTAMP_PATTERN = /^\*\*\d{2}:\d{2}\*\*/;
@@ -11718,7 +12182,7 @@ function runValidationPipeline(content, format, options = {}) {
11718
12182
  // src/core/write/mutation-helpers.ts
11719
12183
  init_writer();
11720
12184
  import fs19 from "fs/promises";
11721
- import path19 from "path";
12185
+ import path20 from "path";
11722
12186
  init_constants();
11723
12187
  init_writer();
11724
12188
  function formatMcpResult(result) {
@@ -11767,7 +12231,7 @@ async function handleGitCommit(vaultPath2, notePath, commit, prefix) {
11767
12231
  return info;
11768
12232
  }
11769
12233
  async function ensureFileExists(vaultPath2, notePath) {
11770
- const fullPath = path19.join(vaultPath2, notePath);
12234
+ const fullPath = path20.join(vaultPath2, notePath);
11771
12235
  try {
11772
12236
  await fs19.access(fullPath);
11773
12237
  return null;
@@ -11826,7 +12290,7 @@ async function withVaultFile(options, operation) {
11826
12290
  if ("error" in result) {
11827
12291
  return formatMcpResult(result.error);
11828
12292
  }
11829
- const fullPath = path19.join(vaultPath2, notePath);
12293
+ const fullPath = path20.join(vaultPath2, notePath);
11830
12294
  const statBefore = await fs19.stat(fullPath);
11831
12295
  if (statBefore.mtimeMs !== result.mtimeMs) {
11832
12296
  console.warn(`[withVaultFile] External modification detected on ${notePath}, re-reading and retrying`);
@@ -11889,10 +12353,10 @@ async function withVaultFrontmatter(options, operation) {
11889
12353
 
11890
12354
  // src/tools/write/mutations.ts
11891
12355
  async function createNoteFromTemplate(vaultPath2, notePath, config) {
11892
- const fullPath = path20.join(vaultPath2, notePath);
11893
- await fs20.mkdir(path20.dirname(fullPath), { recursive: true });
12356
+ const fullPath = path21.join(vaultPath2, notePath);
12357
+ await fs20.mkdir(path21.dirname(fullPath), { recursive: true });
11894
12358
  const templates = config.templates || {};
11895
- const filename = path20.basename(notePath, ".md").toLowerCase();
12359
+ const filename = path21.basename(notePath, ".md").toLowerCase();
11896
12360
  let templatePath;
11897
12361
  const dailyPattern = /^\d{4}-\d{2}-\d{2}/;
11898
12362
  const weeklyPattern = /^\d{4}-W\d{2}/;
@@ -11913,10 +12377,10 @@ async function createNoteFromTemplate(vaultPath2, notePath, config) {
11913
12377
  let templateContent;
11914
12378
  if (templatePath) {
11915
12379
  try {
11916
- const absTemplatePath = path20.join(vaultPath2, templatePath);
12380
+ const absTemplatePath = path21.join(vaultPath2, templatePath);
11917
12381
  templateContent = await fs20.readFile(absTemplatePath, "utf-8");
11918
12382
  } catch {
11919
- const title = path20.basename(notePath, ".md");
12383
+ const title = path21.basename(notePath, ".md");
11920
12384
  templateContent = `---
11921
12385
  ---
11922
12386
 
@@ -11925,7 +12389,7 @@ async function createNoteFromTemplate(vaultPath2, notePath, config) {
11925
12389
  templatePath = void 0;
11926
12390
  }
11927
12391
  } else {
11928
- const title = path20.basename(notePath, ".md");
12392
+ const title = path21.basename(notePath, ".md");
11929
12393
  templateContent = `---
11930
12394
  ---
11931
12395
 
@@ -11934,7 +12398,7 @@ async function createNoteFromTemplate(vaultPath2, notePath, config) {
11934
12398
  }
11935
12399
  const now = /* @__PURE__ */ new Date();
11936
12400
  const dateStr = now.toISOString().split("T")[0];
11937
- templateContent = templateContent.replace(/\{\{date\}\}/g, dateStr).replace(/\{\{title\}\}/g, path20.basename(notePath, ".md"));
12401
+ templateContent = templateContent.replace(/\{\{date\}\}/g, dateStr).replace(/\{\{title\}\}/g, path21.basename(notePath, ".md"));
11938
12402
  const matter9 = (await import("gray-matter")).default;
11939
12403
  const parsed = matter9(templateContent);
11940
12404
  if (!parsed.data.date) {
@@ -11966,14 +12430,15 @@ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", con
11966
12430
  validate: z11.boolean().default(true).describe("Check input for common issues (double timestamps, non-markdown bullets, etc.)"),
11967
12431
  normalize: z11.boolean().default(true).describe("Auto-fix common issues before formatting (replace \u2022 with -, trim excessive whitespace, etc.)"),
11968
12432
  guardrails: z11.enum(["warn", "strict", "off"]).default("warn").describe('Output validation mode: "warn" returns issues but proceeds, "strict" blocks on errors, "off" disables'),
12433
+ linkedEntities: z11.array(z11.string()).optional().describe("Entity names already linked in the content. When skipWikilinks=true, these are tracked for feedback without re-processing the content."),
11969
12434
  agent_id: z11.string().optional().describe('Agent identifier for multi-agent scoping (e.g., "claude-opus", "planning-agent")'),
11970
12435
  session_id: z11.string().optional().describe('Session identifier for conversation scoping (e.g., "sess-abc123")')
11971
12436
  },
11972
- async ({ path: notePath, section, content, create_if_missing, position, format, commit, skipWikilinks, preserveListNesting, bumpHeadings, suggestOutgoingLinks, maxSuggestions, validate, normalize, guardrails, agent_id, session_id }) => {
12437
+ async ({ path: notePath, section, content, create_if_missing, position, format, commit, skipWikilinks, preserveListNesting, bumpHeadings, suggestOutgoingLinks, maxSuggestions, validate, normalize, guardrails, linkedEntities, agent_id, session_id }) => {
11973
12438
  let noteCreated = false;
11974
12439
  let templateUsed;
11975
12440
  if (create_if_missing) {
11976
- const fullPath = path20.join(vaultPath2, notePath);
12441
+ const fullPath = path21.join(vaultPath2, notePath);
11977
12442
  try {
11978
12443
  await fs20.access(fullPath);
11979
12444
  } catch {
@@ -12004,6 +12469,12 @@ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", con
12004
12469
  }
12005
12470
  let workingContent = validationResult.content;
12006
12471
  let { content: processedContent, wikilinkInfo } = maybeApplyWikilinks(workingContent, skipWikilinks, notePath);
12472
+ if (linkedEntities?.length) {
12473
+ const stateDb2 = getWriteStateDb();
12474
+ if (stateDb2) {
12475
+ trackWikilinkApplications(stateDb2, notePath, linkedEntities);
12476
+ }
12477
+ }
12007
12478
  const _debug = {
12008
12479
  entityCount: getEntityIndexStats().totalEntities,
12009
12480
  indexReady: getEntityIndexStats().ready,
@@ -12426,7 +12897,7 @@ Example: vault_update_frontmatter({ path: "projects/alpha.md", frontmatter: { st
12426
12897
  init_writer();
12427
12898
  import { z as z14 } from "zod";
12428
12899
  import fs21 from "fs/promises";
12429
- import path21 from "path";
12900
+ import path22 from "path";
12430
12901
  function registerNoteTools(server2, vaultPath2, getIndex) {
12431
12902
  server2.tool(
12432
12903
  "vault_create_note",
@@ -12439,7 +12910,7 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
12439
12910
  overwrite: z14.boolean().default(false).describe("If true, overwrite existing file"),
12440
12911
  commit: z14.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
12441
12912
  skipWikilinks: z14.boolean().default(false).describe("If true, skip auto-wikilink application (wikilinks are applied by default)"),
12442
- suggestOutgoingLinks: z14.boolean().default(false).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]], [[Philosophy]]"). Disabled by default for templates; enable for content-rich notes.'),
12913
+ suggestOutgoingLinks: z14.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]], [[Philosophy]]").'),
12443
12914
  maxSuggestions: z14.number().min(1).max(10).default(3).describe("Maximum number of suggested wikilinks to append (1-10, default: 3)"),
12444
12915
  agent_id: z14.string().optional().describe("Agent identifier for multi-agent scoping"),
12445
12916
  session_id: z14.string().optional().describe("Session identifier for conversation scoping")
@@ -12449,23 +12920,23 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
12449
12920
  if (!validatePath(vaultPath2, notePath)) {
12450
12921
  return formatMcpResult(errorResult(notePath, "Invalid path: path traversal not allowed"));
12451
12922
  }
12452
- const fullPath = path21.join(vaultPath2, notePath);
12923
+ const fullPath = path22.join(vaultPath2, notePath);
12453
12924
  const existsCheck = await ensureFileExists(vaultPath2, notePath);
12454
12925
  if (existsCheck === null && !overwrite) {
12455
12926
  return formatMcpResult(errorResult(notePath, `File already exists: ${notePath}. Use overwrite=true to replace.`));
12456
12927
  }
12457
- const dir = path21.dirname(fullPath);
12928
+ const dir = path22.dirname(fullPath);
12458
12929
  await fs21.mkdir(dir, { recursive: true });
12459
12930
  let effectiveContent = content;
12460
12931
  let effectiveFrontmatter = frontmatter;
12461
12932
  if (template) {
12462
- const templatePath = path21.join(vaultPath2, template);
12933
+ const templatePath = path22.join(vaultPath2, template);
12463
12934
  try {
12464
12935
  const raw = await fs21.readFile(templatePath, "utf-8");
12465
12936
  const matter9 = (await import("gray-matter")).default;
12466
12937
  const parsed = matter9(raw);
12467
12938
  const dateStr = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
12468
- const title = path21.basename(notePath, ".md");
12939
+ const title = path22.basename(notePath, ".md");
12469
12940
  let templateContent = parsed.content.replace(/\{\{date\}\}/g, dateStr).replace(/\{\{title\}\}/g, title);
12470
12941
  if (content) {
12471
12942
  templateContent = templateContent.trimEnd() + "\n\n" + content;
@@ -12484,7 +12955,7 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
12484
12955
  effectiveFrontmatter.created = now.toISOString();
12485
12956
  }
12486
12957
  const warnings = [];
12487
- const noteName = path21.basename(notePath, ".md");
12958
+ const noteName = path22.basename(notePath, ".md");
12488
12959
  const existingAliases = Array.isArray(effectiveFrontmatter?.aliases) ? effectiveFrontmatter.aliases.filter((a) => typeof a === "string") : [];
12489
12960
  const preflight = await checkPreflightSimilarity(noteName);
12490
12961
  if (preflight.existingEntity) {
@@ -12601,7 +13072,7 @@ ${sources}`;
12601
13072
  }
12602
13073
  return formatMcpResult(errorResult(notePath, previewLines.join("\n")));
12603
13074
  }
12604
- const fullPath = path21.join(vaultPath2, notePath);
13075
+ const fullPath = path22.join(vaultPath2, notePath);
12605
13076
  await fs21.unlink(fullPath);
12606
13077
  const gitInfo = await handleGitCommit(vaultPath2, notePath, commit, "[Flywheel:Delete]");
12607
13078
  const message = backlinkWarning ? `Deleted note: ${notePath}
@@ -12621,7 +13092,7 @@ Warning: ${backlinkWarning}` : `Deleted note: ${notePath}`;
12621
13092
  init_writer();
12622
13093
  import { z as z15 } from "zod";
12623
13094
  import fs22 from "fs/promises";
12624
- import path22 from "path";
13095
+ import path23 from "path";
12625
13096
  import matter6 from "gray-matter";
12626
13097
  function escapeRegex(str) {
12627
13098
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -12640,7 +13111,7 @@ function extractWikilinks2(content) {
12640
13111
  return wikilinks;
12641
13112
  }
12642
13113
  function getTitleFromPath(filePath) {
12643
- return path22.basename(filePath, ".md");
13114
+ return path23.basename(filePath, ".md");
12644
13115
  }
12645
13116
  async function findBacklinks(vaultPath2, targetTitle, targetAliases) {
12646
13117
  const results = [];
@@ -12649,7 +13120,7 @@ async function findBacklinks(vaultPath2, targetTitle, targetAliases) {
12649
13120
  const files = [];
12650
13121
  const entries = await fs22.readdir(dir, { withFileTypes: true });
12651
13122
  for (const entry of entries) {
12652
- const fullPath = path22.join(dir, entry.name);
13123
+ const fullPath = path23.join(dir, entry.name);
12653
13124
  if (entry.isDirectory() && !entry.name.startsWith(".")) {
12654
13125
  files.push(...await scanDir(fullPath));
12655
13126
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
@@ -12660,7 +13131,7 @@ async function findBacklinks(vaultPath2, targetTitle, targetAliases) {
12660
13131
  }
12661
13132
  const allFiles = await scanDir(vaultPath2);
12662
13133
  for (const filePath of allFiles) {
12663
- const relativePath = path22.relative(vaultPath2, filePath);
13134
+ const relativePath = path23.relative(vaultPath2, filePath);
12664
13135
  const content = await fs22.readFile(filePath, "utf-8");
12665
13136
  const wikilinks = extractWikilinks2(content);
12666
13137
  const matchingLinks = [];
@@ -12680,7 +13151,7 @@ async function findBacklinks(vaultPath2, targetTitle, targetAliases) {
12680
13151
  return results;
12681
13152
  }
12682
13153
  async function updateBacklinksInFile(vaultPath2, filePath, oldTitles, newTitle) {
12683
- const fullPath = path22.join(vaultPath2, filePath);
13154
+ const fullPath = path23.join(vaultPath2, filePath);
12684
13155
  const raw = await fs22.readFile(fullPath, "utf-8");
12685
13156
  const parsed = matter6(raw);
12686
13157
  let content = parsed.content;
@@ -12747,8 +13218,8 @@ function registerMoveNoteTools(server2, vaultPath2) {
12747
13218
  };
12748
13219
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
12749
13220
  }
12750
- const oldFullPath = path22.join(vaultPath2, oldPath);
12751
- const newFullPath = path22.join(vaultPath2, newPath);
13221
+ const oldFullPath = path23.join(vaultPath2, oldPath);
13222
+ const newFullPath = path23.join(vaultPath2, newPath);
12752
13223
  try {
12753
13224
  await fs22.access(oldFullPath);
12754
13225
  } catch {
@@ -12798,7 +13269,7 @@ function registerMoveNoteTools(server2, vaultPath2) {
12798
13269
  }
12799
13270
  }
12800
13271
  }
12801
- const destDir = path22.dirname(newFullPath);
13272
+ const destDir = path23.dirname(newFullPath);
12802
13273
  await fs22.mkdir(destDir, { recursive: true });
12803
13274
  await fs22.rename(oldFullPath, newFullPath);
12804
13275
  let gitCommit;
@@ -12884,10 +13355,10 @@ function registerMoveNoteTools(server2, vaultPath2) {
12884
13355
  if (sanitizedTitle !== newTitle) {
12885
13356
  console.error(`[Flywheel] Title sanitized: "${newTitle}" \u2192 "${sanitizedTitle}"`);
12886
13357
  }
12887
- const fullPath = path22.join(vaultPath2, notePath);
12888
- const dir = path22.dirname(notePath);
12889
- const newPath = dir === "." ? `${sanitizedTitle}.md` : path22.join(dir, `${sanitizedTitle}.md`);
12890
- const newFullPath = path22.join(vaultPath2, newPath);
13358
+ const fullPath = path23.join(vaultPath2, notePath);
13359
+ const dir = path23.dirname(notePath);
13360
+ const newPath = dir === "." ? `${sanitizedTitle}.md` : path23.join(dir, `${sanitizedTitle}.md`);
13361
+ const newFullPath = path23.join(vaultPath2, newPath);
12891
13362
  try {
12892
13363
  await fs22.access(fullPath);
12893
13364
  } catch {
@@ -13244,7 +13715,7 @@ init_schema();
13244
13715
  // src/core/write/policy/parser.ts
13245
13716
  init_schema();
13246
13717
  import fs24 from "fs/promises";
13247
- import path23 from "path";
13718
+ import path24 from "path";
13248
13719
  import matter7 from "gray-matter";
13249
13720
  function parseYaml(content) {
13250
13721
  const parsed = matter7(`---
@@ -13293,13 +13764,13 @@ async function loadPolicyFile(filePath) {
13293
13764
  }
13294
13765
  }
13295
13766
  async function loadPolicy(vaultPath2, policyName) {
13296
- const policiesDir = path23.join(vaultPath2, ".claude", "policies");
13297
- const policyPath = path23.join(policiesDir, `${policyName}.yaml`);
13767
+ const policiesDir = path24.join(vaultPath2, ".claude", "policies");
13768
+ const policyPath = path24.join(policiesDir, `${policyName}.yaml`);
13298
13769
  try {
13299
13770
  await fs24.access(policyPath);
13300
13771
  return loadPolicyFile(policyPath);
13301
13772
  } catch {
13302
- const ymlPath = path23.join(policiesDir, `${policyName}.yml`);
13773
+ const ymlPath = path24.join(policiesDir, `${policyName}.yml`);
13303
13774
  try {
13304
13775
  await fs24.access(ymlPath);
13305
13776
  return loadPolicyFile(ymlPath);
@@ -13440,7 +13911,7 @@ init_conditions();
13440
13911
  init_schema();
13441
13912
  init_writer();
13442
13913
  import fs26 from "fs/promises";
13443
- import path25 from "path";
13914
+ import path26 from "path";
13444
13915
  init_constants();
13445
13916
  async function executeStep(step, vaultPath2, context, conditionResults) {
13446
13917
  const { execute, reason } = shouldStepExecute(step.when, conditionResults);
@@ -13456,12 +13927,17 @@ async function executeStep(step, vaultPath2, context, conditionResults) {
13456
13927
  const resolvedParams = interpolateObject(step.params, context);
13457
13928
  try {
13458
13929
  const result = await executeToolCall(step.tool, resolvedParams, vaultPath2, context);
13930
+ const outputs = {};
13931
+ if (result.path) {
13932
+ outputs.path = result.path;
13933
+ }
13459
13934
  return {
13460
13935
  stepId: step.id,
13461
13936
  success: result.success,
13462
13937
  message: result.message,
13463
13938
  path: result.path,
13464
- preview: result.preview
13939
+ preview: result.preview,
13940
+ outputs
13465
13941
  };
13466
13942
  } catch (error) {
13467
13943
  return {
@@ -13509,7 +13985,7 @@ async function executeAddToSection(params, vaultPath2, context) {
13509
13985
  const preserveListNesting = params.preserveListNesting !== false;
13510
13986
  const suggestOutgoingLinks = params.suggestOutgoingLinks !== false;
13511
13987
  const maxSuggestions = Number(params.maxSuggestions) || 3;
13512
- const fullPath = path25.join(vaultPath2, notePath);
13988
+ const fullPath = path26.join(vaultPath2, notePath);
13513
13989
  try {
13514
13990
  await fs26.access(fullPath);
13515
13991
  } catch {
@@ -13549,7 +14025,7 @@ async function executeRemoveFromSection(params, vaultPath2) {
13549
14025
  const pattern = String(params.pattern || "");
13550
14026
  const mode = params.mode || "first";
13551
14027
  const useRegex = Boolean(params.useRegex);
13552
- const fullPath = path25.join(vaultPath2, notePath);
14028
+ const fullPath = path26.join(vaultPath2, notePath);
13553
14029
  try {
13554
14030
  await fs26.access(fullPath);
13555
14031
  } catch {
@@ -13580,7 +14056,7 @@ async function executeReplaceInSection(params, vaultPath2, context) {
13580
14056
  const mode = params.mode || "first";
13581
14057
  const useRegex = Boolean(params.useRegex);
13582
14058
  const skipWikilinks = Boolean(params.skipWikilinks);
13583
- const fullPath = path25.join(vaultPath2, notePath);
14059
+ const fullPath = path26.join(vaultPath2, notePath);
13584
14060
  try {
13585
14061
  await fs26.access(fullPath);
13586
14062
  } catch {
@@ -13623,7 +14099,7 @@ async function executeCreateNote(params, vaultPath2, context) {
13623
14099
  if (!validatePath(vaultPath2, notePath)) {
13624
14100
  return { success: false, message: "Invalid path: path traversal not allowed", path: notePath };
13625
14101
  }
13626
- const fullPath = path25.join(vaultPath2, notePath);
14102
+ const fullPath = path26.join(vaultPath2, notePath);
13627
14103
  try {
13628
14104
  await fs26.access(fullPath);
13629
14105
  if (!overwrite) {
@@ -13631,7 +14107,7 @@ async function executeCreateNote(params, vaultPath2, context) {
13631
14107
  }
13632
14108
  } catch {
13633
14109
  }
13634
- const dir = path25.dirname(fullPath);
14110
+ const dir = path26.dirname(fullPath);
13635
14111
  await fs26.mkdir(dir, { recursive: true });
13636
14112
  const { content: processedContent } = maybeApplyWikilinks(content, skipWikilinks, notePath);
13637
14113
  await writeVaultFile(vaultPath2, notePath, processedContent, frontmatter);
@@ -13651,7 +14127,7 @@ async function executeDeleteNote(params, vaultPath2) {
13651
14127
  if (!validatePath(vaultPath2, notePath)) {
13652
14128
  return { success: false, message: "Invalid path: path traversal not allowed", path: notePath };
13653
14129
  }
13654
- const fullPath = path25.join(vaultPath2, notePath);
14130
+ const fullPath = path26.join(vaultPath2, notePath);
13655
14131
  try {
13656
14132
  await fs26.access(fullPath);
13657
14133
  } catch {
@@ -13668,7 +14144,7 @@ async function executeToggleTask(params, vaultPath2) {
13668
14144
  const notePath = String(params.path || "");
13669
14145
  const task = String(params.task || "");
13670
14146
  const section = params.section ? String(params.section) : void 0;
13671
- const fullPath = path25.join(vaultPath2, notePath);
14147
+ const fullPath = path26.join(vaultPath2, notePath);
13672
14148
  try {
13673
14149
  await fs26.access(fullPath);
13674
14150
  } catch {
@@ -13711,7 +14187,7 @@ async function executeAddTask(params, vaultPath2, context) {
13711
14187
  const completed = Boolean(params.completed);
13712
14188
  const skipWikilinks = Boolean(params.skipWikilinks);
13713
14189
  const preserveListNesting = params.preserveListNesting !== false;
13714
- const fullPath = path25.join(vaultPath2, notePath);
14190
+ const fullPath = path26.join(vaultPath2, notePath);
13715
14191
  try {
13716
14192
  await fs26.access(fullPath);
13717
14193
  } catch {
@@ -13748,7 +14224,7 @@ async function executeAddTask(params, vaultPath2, context) {
13748
14224
  async function executeUpdateFrontmatter(params, vaultPath2) {
13749
14225
  const notePath = String(params.path || "");
13750
14226
  const updates = params.frontmatter || {};
13751
- const fullPath = path25.join(vaultPath2, notePath);
14227
+ const fullPath = path26.join(vaultPath2, notePath);
13752
14228
  try {
13753
14229
  await fs26.access(fullPath);
13754
14230
  } catch {
@@ -13770,7 +14246,7 @@ async function executeAddFrontmatterField(params, vaultPath2) {
13770
14246
  const notePath = String(params.path || "");
13771
14247
  const key = String(params.key || "");
13772
14248
  const value = params.value;
13773
- const fullPath = path25.join(vaultPath2, notePath);
14249
+ const fullPath = path26.join(vaultPath2, notePath);
13774
14250
  try {
13775
14251
  await fs26.access(fullPath);
13776
14252
  } catch {
@@ -13852,6 +14328,9 @@ async function executePolicy(policy, vaultPath2, variables, commit = false) {
13852
14328
  if (result.path && result.success && !result.skipped) {
13853
14329
  filesModified.add(result.path);
13854
14330
  }
14331
+ if (result.success && !result.skipped && result.outputs) {
14332
+ context.steps[step.id] = result.outputs;
14333
+ }
13855
14334
  if (!result.success && !result.skipped) {
13856
14335
  if (commit && filesModified.size > 0) {
13857
14336
  await rollbackChanges(vaultPath2, originalContents, filesModified);
@@ -13930,7 +14409,7 @@ async function executePolicy(policy, vaultPath2, variables, commit = false) {
13930
14409
  async function rollbackChanges(vaultPath2, originalContents, filesModified) {
13931
14410
  for (const filePath of filesModified) {
13932
14411
  const original = originalContents.get(filePath);
13933
- const fullPath = path25.join(vaultPath2, filePath);
14412
+ const fullPath = path26.join(vaultPath2, filePath);
13934
14413
  if (original === null) {
13935
14414
  try {
13936
14415
  await fs26.unlink(fullPath);
@@ -13985,9 +14464,9 @@ async function previewPolicy(policy, vaultPath2, variables) {
13985
14464
 
13986
14465
  // src/core/write/policy/storage.ts
13987
14466
  import fs27 from "fs/promises";
13988
- import path26 from "path";
14467
+ import path27 from "path";
13989
14468
  function getPoliciesDir(vaultPath2) {
13990
- return path26.join(vaultPath2, ".claude", "policies");
14469
+ return path27.join(vaultPath2, ".claude", "policies");
13991
14470
  }
13992
14471
  async function ensurePoliciesDir(vaultPath2) {
13993
14472
  const dir = getPoliciesDir(vaultPath2);
@@ -14002,7 +14481,7 @@ async function listPolicies(vaultPath2) {
14002
14481
  if (!file.endsWith(".yaml") && !file.endsWith(".yml")) {
14003
14482
  continue;
14004
14483
  }
14005
- const filePath = path26.join(dir, file);
14484
+ const filePath = path27.join(dir, file);
14006
14485
  const stat3 = await fs27.stat(filePath);
14007
14486
  const content = await fs27.readFile(filePath, "utf-8");
14008
14487
  const metadata = extractPolicyMetadata(content);
@@ -14027,7 +14506,7 @@ async function writePolicyRaw(vaultPath2, policyName, content, overwrite = false
14027
14506
  const dir = getPoliciesDir(vaultPath2);
14028
14507
  await ensurePoliciesDir(vaultPath2);
14029
14508
  const filename = `${policyName}.yaml`;
14030
- const filePath = path26.join(dir, filename);
14509
+ const filePath = path27.join(dir, filename);
14031
14510
  if (!overwrite) {
14032
14511
  try {
14033
14512
  await fs27.access(filePath);
@@ -14571,7 +15050,7 @@ import { z as z20 } from "zod";
14571
15050
 
14572
15051
  // src/core/write/tagRename.ts
14573
15052
  import * as fs28 from "fs/promises";
14574
- import * as path27 from "path";
15053
+ import * as path28 from "path";
14575
15054
  import matter8 from "gray-matter";
14576
15055
  import { getProtectedZones } from "@velvetmonkey/vault-core";
14577
15056
  function getNotesInFolder3(index, folder) {
@@ -14677,7 +15156,7 @@ async function renameTag(index, vaultPath2, oldTag, newTag, options) {
14677
15156
  const previews = [];
14678
15157
  let totalChanges = 0;
14679
15158
  for (const note of affectedNotes) {
14680
- const fullPath = path27.join(vaultPath2, note.path);
15159
+ const fullPath = path28.join(vaultPath2, note.path);
14681
15160
  let fileContent;
14682
15161
  try {
14683
15162
  fileContent = await fs28.readFile(fullPath, "utf-8");
@@ -14812,21 +15291,26 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
14812
15291
  "wikilink_feedback",
14813
15292
  {
14814
15293
  title: "Wikilink Feedback",
14815
- description: 'Report and query wikilink accuracy feedback. Modes: "report" (record feedback), "list" (view recent feedback), "stats" (entity accuracy statistics), "dashboard" (full feedback loop data for visualization). Entities with >=30% false positive rate (and >=10 samples) are auto-suppressed from future wikilink application.',
15294
+ description: 'Report and query wikilink accuracy feedback. Modes: "report" (record feedback), "list" (view recent feedback), "stats" (entity accuracy statistics), "dashboard" (full feedback loop data), "entity_timeline" (score history for an entity), "layer_timeseries" (per-layer contribution over time), "snapshot_diff" (compare two graph snapshots).',
14816
15295
  inputSchema: {
14817
- mode: z21.enum(["report", "list", "stats", "dashboard"]).describe("Operation mode"),
14818
- entity: z21.string().optional().describe("Entity name (required for report mode, optional filter for list/stats)"),
15296
+ mode: z21.enum(["report", "list", "stats", "dashboard", "entity_timeline", "layer_timeseries", "snapshot_diff"]).describe("Operation mode"),
15297
+ entity: z21.string().optional().describe("Entity name (required for report and entity_timeline modes, optional filter for list/stats)"),
14819
15298
  note_path: z21.string().optional().describe("Note path where the wikilink appeared (for report mode)"),
14820
15299
  context: z21.string().optional().describe("Surrounding text context (for report mode)"),
14821
15300
  correct: z21.boolean().optional().describe("Whether the wikilink was correct (for report mode)"),
14822
- limit: z21.number().optional().describe("Max entries to return for list mode (default: 20)")
15301
+ limit: z21.number().optional().describe("Max entries to return (default: 20 for list, 100 for entity_timeline)"),
15302
+ days_back: z21.number().optional().describe("Days to look back (default: 30)"),
15303
+ granularity: z21.enum(["day", "week"]).optional().describe("Time bucket granularity for layer_timeseries (default: day)"),
15304
+ timestamp_before: z21.number().optional().describe("Earlier timestamp for snapshot_diff"),
15305
+ timestamp_after: z21.number().optional().describe("Later timestamp for snapshot_diff")
14823
15306
  }
14824
15307
  },
14825
- async ({ mode, entity, note_path, context, correct, limit }) => {
15308
+ async ({ mode, entity, note_path, context, correct, limit, days_back, granularity, timestamp_before, timestamp_after }) => {
14826
15309
  const stateDb2 = getStateDb();
14827
15310
  if (!stateDb2) {
14828
15311
  return {
14829
- content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available" }) }]
15312
+ content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available \u2014 database not initialized yet" }) }],
15313
+ isError: true
14830
15314
  };
14831
15315
  }
14832
15316
  let result;
@@ -14837,7 +15321,15 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
14837
15321
  content: [{ type: "text", text: JSON.stringify({ error: "entity and correct are required for report mode" }) }]
14838
15322
  };
14839
15323
  }
14840
- recordFeedback(stateDb2, entity, context || "", note_path || "", correct);
15324
+ try {
15325
+ recordFeedback(stateDb2, entity, context || "", note_path || "", correct);
15326
+ } catch (e) {
15327
+ return {
15328
+ content: [{ type: "text", text: JSON.stringify({
15329
+ error: `Failed to record feedback: ${e instanceof Error ? e.message : String(e)}`
15330
+ }) }]
15331
+ };
15332
+ }
14841
15333
  const suppressionUpdated = updateSuppressionList(stateDb2) > 0;
14842
15334
  result = {
14843
15335
  mode: "report",
@@ -14870,7 +15362,7 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
14870
15362
  break;
14871
15363
  }
14872
15364
  case "dashboard": {
14873
- const dashboard = getDashboardData(stateDb2);
15365
+ const dashboard = getExtendedDashboardData(stateDb2);
14874
15366
  result = {
14875
15367
  mode: "dashboard",
14876
15368
  dashboard,
@@ -14879,6 +15371,44 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
14879
15371
  };
14880
15372
  break;
14881
15373
  }
15374
+ case "entity_timeline": {
15375
+ if (!entity) {
15376
+ return {
15377
+ content: [{ type: "text", text: JSON.stringify({ error: "entity is required for entity_timeline mode" }) }]
15378
+ };
15379
+ }
15380
+ const timeline = getEntityScoreTimeline(stateDb2, entity, days_back ?? 30, limit ?? 100);
15381
+ result = {
15382
+ mode: "entity_timeline",
15383
+ entity,
15384
+ timeline,
15385
+ count: timeline.length
15386
+ };
15387
+ break;
15388
+ }
15389
+ case "layer_timeseries": {
15390
+ const timeseries = getLayerContributionTimeseries(stateDb2, granularity ?? "day", days_back ?? 30);
15391
+ result = {
15392
+ mode: "layer_timeseries",
15393
+ granularity: granularity ?? "day",
15394
+ timeseries,
15395
+ buckets: timeseries.length
15396
+ };
15397
+ break;
15398
+ }
15399
+ case "snapshot_diff": {
15400
+ if (!timestamp_before || !timestamp_after) {
15401
+ return {
15402
+ content: [{ type: "text", text: JSON.stringify({ error: "timestamp_before and timestamp_after are required for snapshot_diff mode" }) }]
15403
+ };
15404
+ }
15405
+ const diff = compareGraphSnapshots(stateDb2, timestamp_before, timestamp_after);
15406
+ result = {
15407
+ mode: "snapshot_diff",
15408
+ diff
15409
+ };
15410
+ break;
15411
+ }
14882
15412
  }
14883
15413
  return {
14884
15414
  content: [
@@ -15264,8 +15794,8 @@ function getNoteAccessFrequency(stateDb2, daysBack = 30) {
15264
15794
  }
15265
15795
  }
15266
15796
  }
15267
- return Array.from(noteMap.entries()).map(([path30, stats]) => ({
15268
- path: path30,
15797
+ return Array.from(noteMap.entries()).map(([path31, stats]) => ({
15798
+ path: path31,
15269
15799
  access_count: stats.access_count,
15270
15800
  last_accessed: stats.last_accessed,
15271
15801
  tools_used: Array.from(stats.tools)
@@ -15418,7 +15948,7 @@ import { z as z25 } from "zod";
15418
15948
 
15419
15949
  // src/core/read/similarity.ts
15420
15950
  import * as fs29 from "fs";
15421
- import * as path28 from "path";
15951
+ import * as path29 from "path";
15422
15952
  var STOP_WORDS = /* @__PURE__ */ new Set([
15423
15953
  "the",
15424
15954
  "be",
@@ -15555,7 +16085,7 @@ function extractKeyTerms(content, maxTerms = 15) {
15555
16085
  }
15556
16086
  function findSimilarNotes(db4, vaultPath2, index, sourcePath, options = {}) {
15557
16087
  const limit = options.limit ?? 10;
15558
- const absPath = path28.join(vaultPath2, sourcePath);
16088
+ const absPath = path29.join(vaultPath2, sourcePath);
15559
16089
  let content;
15560
16090
  try {
15561
16091
  content = fs29.readFileSync(absPath, "utf-8");
@@ -15683,7 +16213,7 @@ function registerSimilarityTools(server2, getIndex, getVaultPath, getStateDb) {
15683
16213
  exclude_linked: z25.boolean().optional().describe("Exclude notes already linked to/from the source note (default: true)")
15684
16214
  }
15685
16215
  },
15686
- async ({ path: path30, limit, exclude_linked }) => {
16216
+ async ({ path: path31, limit, exclude_linked }) => {
15687
16217
  const index = getIndex();
15688
16218
  const vaultPath2 = getVaultPath();
15689
16219
  const stateDb2 = getStateDb();
@@ -15692,10 +16222,10 @@ function registerSimilarityTools(server2, getIndex, getVaultPath, getStateDb) {
15692
16222
  content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available" }) }]
15693
16223
  };
15694
16224
  }
15695
- if (!index.notes.has(path30)) {
16225
+ if (!index.notes.has(path31)) {
15696
16226
  return {
15697
16227
  content: [{ type: "text", text: JSON.stringify({
15698
- error: `Note not found: ${path30}`,
16228
+ error: `Note not found: ${path31}`,
15699
16229
  hint: "Use the full relative path including .md extension"
15700
16230
  }, null, 2) }]
15701
16231
  };
@@ -15706,12 +16236,12 @@ function registerSimilarityTools(server2, getIndex, getVaultPath, getStateDb) {
15706
16236
  };
15707
16237
  const useHybrid = hasEmbeddingsIndex();
15708
16238
  const method = useHybrid ? "hybrid" : "bm25";
15709
- const results = useHybrid ? await findHybridSimilarNotes(stateDb2.db, vaultPath2, index, path30, opts) : findSimilarNotes(stateDb2.db, vaultPath2, index, path30, opts);
16239
+ const results = useHybrid ? await findHybridSimilarNotes(stateDb2.db, vaultPath2, index, path31, opts) : findSimilarNotes(stateDb2.db, vaultPath2, index, path31, opts);
15710
16240
  return {
15711
16241
  content: [{
15712
16242
  type: "text",
15713
16243
  text: JSON.stringify({
15714
- source: path30,
16244
+ source: path31,
15715
16245
  method,
15716
16246
  exclude_linked: exclude_linked ?? true,
15717
16247
  count: results.length,
@@ -15949,6 +16479,7 @@ function registerMergeTools2(server2, getStateDb) {
15949
16479
 
15950
16480
  // src/index.ts
15951
16481
  import * as fs30 from "node:fs/promises";
16482
+ import { createHash as createHash2 } from "node:crypto";
15952
16483
 
15953
16484
  // src/resources/vault.ts
15954
16485
  function registerVaultResources(server2, getIndex) {
@@ -16054,7 +16585,16 @@ function registerVaultResources(server2, getIndex) {
16054
16585
  }
16055
16586
 
16056
16587
  // src/index.ts
16588
+ var __filename = fileURLToPath(import.meta.url);
16589
+ var __dirname = dirname4(__filename);
16590
+ var pkg = JSON.parse(readFileSync4(join16(__dirname, "../package.json"), "utf-8"));
16057
16591
  var vaultPath = process.env.PROJECT_PATH || process.env.VAULT_PATH || findVaultRoot();
16592
+ var resolvedVaultPath;
16593
+ try {
16594
+ resolvedVaultPath = realpathSync(vaultPath).replace(/\\/g, "/");
16595
+ } catch {
16596
+ resolvedVaultPath = vaultPath.replace(/\\/g, "/");
16597
+ }
16058
16598
  var vaultIndex;
16059
16599
  var flywheelConfig = {};
16060
16600
  var stateDb = null;
@@ -16208,7 +16748,7 @@ var TOOL_CATEGORY = {
16208
16748
  };
16209
16749
  var server = new McpServer({
16210
16750
  name: "flywheel-memory",
16211
- version: "2.0.0"
16751
+ version: pkg.version
16212
16752
  });
16213
16753
  var _registeredCount = 0;
16214
16754
  var _skippedCount = 0;
@@ -16337,7 +16877,7 @@ registerMergeTools2(server, () => stateDb);
16337
16877
  registerVaultResources(server, () => vaultIndex ?? null);
16338
16878
  serverLog("server", `Registered ${_registeredCount} tools, skipped ${_skippedCount}`);
16339
16879
  async function main() {
16340
- serverLog("server", "Starting Flywheel Memory server...");
16880
+ serverLog("server", `Starting Flywheel Memory v${pkg.version}...`);
16341
16881
  serverLog("server", `Vault: ${vaultPath}`);
16342
16882
  const startTime = Date.now();
16343
16883
  try {
@@ -16349,6 +16889,7 @@ async function main() {
16349
16889
  serverLog("statedb", "Injected FTS5, embeddings, task cache handles");
16350
16890
  loadEntityEmbeddingsToMemory();
16351
16891
  setWriteStateDb(stateDb);
16892
+ setRecencyStateDb(stateDb);
16352
16893
  } catch (err) {
16353
16894
  const msg = err instanceof Error ? err.message : String(err);
16354
16895
  serverLog("statedb", `StateDb initialization failed: ${msg}`, "error");
@@ -16549,21 +17090,105 @@ async function runPostIndexWork(index) {
16549
17090
  }
16550
17091
  if (process.env.FLYWHEEL_WATCH !== "false") {
16551
17092
  const config = parseWatcherConfig();
17093
+ const lastContentHashes = /* @__PURE__ */ new Map();
16552
17094
  serverLog("watcher", `File watcher enabled (debounce: ${config.debounceMs}ms)`);
16553
17095
  const watcher = createVaultWatcher({
16554
17096
  vaultPath,
16555
17097
  config,
16556
17098
  onBatch: async (batch) => {
16557
- serverLog("watcher", `Processing ${batch.events.length} file changes`);
17099
+ const vaultPrefixes = /* @__PURE__ */ new Set([
17100
+ vaultPath.replace(/\\/g, "/"),
17101
+ resolvedVaultPath
17102
+ ]);
17103
+ for (const event of batch.events) {
17104
+ const normalized = event.path.replace(/\\/g, "/");
17105
+ let matched = false;
17106
+ for (const prefix of vaultPrefixes) {
17107
+ if (normalized.startsWith(prefix + "/")) {
17108
+ event.path = normalized.slice(prefix.length + 1);
17109
+ matched = true;
17110
+ break;
17111
+ }
17112
+ }
17113
+ if (!matched) {
17114
+ try {
17115
+ const resolved = realpathSync(event.path).replace(/\\/g, "/");
17116
+ for (const prefix of vaultPrefixes) {
17117
+ if (resolved.startsWith(prefix + "/")) {
17118
+ event.path = resolved.slice(prefix.length + 1);
17119
+ matched = true;
17120
+ break;
17121
+ }
17122
+ }
17123
+ } catch {
17124
+ try {
17125
+ const dir = path30.dirname(event.path);
17126
+ const base = path30.basename(event.path);
17127
+ const resolvedDir = realpathSync(dir).replace(/\\/g, "/");
17128
+ for (const prefix of vaultPrefixes) {
17129
+ if (resolvedDir.startsWith(prefix + "/") || resolvedDir === prefix) {
17130
+ const relDir = resolvedDir === prefix ? "" : resolvedDir.slice(prefix.length + 1);
17131
+ event.path = relDir ? `${relDir}/${base}` : base;
17132
+ matched = true;
17133
+ break;
17134
+ }
17135
+ }
17136
+ } catch {
17137
+ }
17138
+ }
17139
+ }
17140
+ }
17141
+ const filteredEvents = [];
17142
+ for (const event of batch.events) {
17143
+ if (event.type === "delete") {
17144
+ filteredEvents.push(event);
17145
+ lastContentHashes.delete(event.path);
17146
+ continue;
17147
+ }
17148
+ try {
17149
+ const content = await fs30.readFile(path30.join(vaultPath, event.path), "utf-8");
17150
+ const hash = createHash2("md5").update(content).digest("hex");
17151
+ if (lastContentHashes.get(event.path) === hash) {
17152
+ serverLog("watcher", `Hash unchanged, skipping: ${event.path}`);
17153
+ continue;
17154
+ }
17155
+ lastContentHashes.set(event.path, hash);
17156
+ filteredEvents.push(event);
17157
+ } catch {
17158
+ filteredEvents.push(event);
17159
+ }
17160
+ }
17161
+ if (filteredEvents.length === 0) {
17162
+ serverLog("watcher", "All files unchanged (hash gate), skipping batch");
17163
+ return;
17164
+ }
17165
+ serverLog("watcher", `Processing ${filteredEvents.length} file changes`);
16558
17166
  const batchStart = Date.now();
16559
- const changedPaths = batch.events.map((e) => e.path);
17167
+ const changedPaths = filteredEvents.map((e) => e.path);
16560
17168
  const tracker = createStepTracker();
16561
17169
  try {
16562
- tracker.start("index_rebuild", { files_changed: batch.events.length, changed_paths: changedPaths });
16563
- vaultIndex = await buildVaultIndex(vaultPath);
17170
+ tracker.start("index_rebuild", { files_changed: filteredEvents.length, changed_paths: changedPaths });
17171
+ if (!vaultIndex) {
17172
+ vaultIndex = await buildVaultIndex(vaultPath);
17173
+ serverLog("watcher", `Index rebuilt (full): ${vaultIndex.notes.size} notes, ${vaultIndex.entities.size} entities`);
17174
+ } else {
17175
+ const absoluteBatch = {
17176
+ ...batch,
17177
+ events: filteredEvents.map((e) => ({
17178
+ ...e,
17179
+ path: path30.join(vaultPath, e.path)
17180
+ }))
17181
+ };
17182
+ const batchResult = await processBatch(vaultIndex, vaultPath, absoluteBatch);
17183
+ serverLog("watcher", `Incremental: ${batchResult.successful}/${batchResult.total} files in ${batchResult.durationMs}ms`);
17184
+ }
16564
17185
  setIndexState("ready");
16565
17186
  tracker.end({ note_count: vaultIndex.notes.size, entity_count: vaultIndex.entities.size, tag_count: vaultIndex.tags.size });
16566
- serverLog("watcher", `Index rebuilt: ${vaultIndex.notes.size} notes, ${vaultIndex.entities.size} entities`);
17187
+ const hubBefore = /* @__PURE__ */ new Map();
17188
+ if (stateDb) {
17189
+ const rows = stateDb.db.prepare("SELECT name, hub_score FROM entities").all();
17190
+ for (const r of rows) hubBefore.set(r.name, r.hub_score);
17191
+ }
16567
17192
  const entitiesBefore = stateDb ? getAllEntitiesFromDb3(stateDb) : [];
16568
17193
  tracker.start("entity_scan", { note_count: vaultIndex.notes.size });
16569
17194
  await updateEntitiesInStateDb();
@@ -16571,11 +17196,6 @@ async function runPostIndexWork(index) {
16571
17196
  const entityDiff = computeEntityDiff(entitiesBefore, entitiesAfter);
16572
17197
  tracker.end({ entity_count: entitiesAfter.length, ...entityDiff });
16573
17198
  serverLog("watcher", `Entity scan: ${entitiesAfter.length} entities`);
16574
- const hubBefore = /* @__PURE__ */ new Map();
16575
- if (stateDb) {
16576
- const rows = stateDb.db.prepare("SELECT name, hub_score FROM entities").all();
16577
- for (const r of rows) hubBefore.set(r.name, r.hub_score);
16578
- }
16579
17199
  tracker.start("hub_scores", { entity_count: entitiesAfter.length });
16580
17200
  const hubUpdated = await exportHubScores(vaultIndex, stateDb);
16581
17201
  const hubDiffs = [];
@@ -16588,17 +17208,35 @@ async function runPostIndexWork(index) {
16588
17208
  }
16589
17209
  tracker.end({ updated: hubUpdated ?? 0, diffs: hubDiffs.slice(0, 10) });
16590
17210
  serverLog("watcher", `Hub scores: ${hubUpdated ?? 0} updated`);
17211
+ tracker.start("recency", { entity_count: entitiesAfter.length });
17212
+ try {
17213
+ const cachedRecency = loadRecencyFromStateDb();
17214
+ const cacheAgeMs = cachedRecency ? Date.now() - (cachedRecency.lastUpdated ?? 0) : Infinity;
17215
+ if (cacheAgeMs >= 60 * 60 * 1e3) {
17216
+ const entities = entitiesAfter.map((e) => ({ name: e.name, path: e.path, aliases: e.aliases }));
17217
+ const recencyIndex2 = await buildRecencyIndex(vaultPath, entities);
17218
+ saveRecencyToStateDb(recencyIndex2);
17219
+ tracker.end({ rebuilt: true, entities: recencyIndex2.lastMentioned.size });
17220
+ serverLog("watcher", `Recency: rebuilt ${recencyIndex2.lastMentioned.size} entities`);
17221
+ } else {
17222
+ tracker.end({ rebuilt: false, cached_age_ms: cacheAgeMs });
17223
+ serverLog("watcher", `Recency: cache valid (${Math.round(cacheAgeMs / 1e3)}s old)`);
17224
+ }
17225
+ } catch (e) {
17226
+ tracker.end({ error: String(e) });
17227
+ serverLog("watcher", `Recency: failed: ${e}`);
17228
+ }
16591
17229
  if (hasEmbeddingsIndex()) {
16592
- tracker.start("note_embeddings", { files: batch.events.length });
17230
+ tracker.start("note_embeddings", { files: filteredEvents.length });
16593
17231
  let embUpdated = 0;
16594
17232
  let embRemoved = 0;
16595
- for (const event of batch.events) {
17233
+ for (const event of filteredEvents) {
16596
17234
  try {
16597
17235
  if (event.type === "delete") {
16598
17236
  removeEmbedding(event.path);
16599
17237
  embRemoved++;
16600
17238
  } else if (event.path.endsWith(".md")) {
16601
- const absPath = path29.join(vaultPath, event.path);
17239
+ const absPath = path30.join(vaultPath, event.path);
16602
17240
  await updateEmbedding(event.path, absPath);
16603
17241
  embUpdated++;
16604
17242
  }
@@ -16611,12 +17249,12 @@ async function runPostIndexWork(index) {
16611
17249
  tracker.skip("note_embeddings", "not built");
16612
17250
  }
16613
17251
  if (hasEntityEmbeddingsIndex() && stateDb) {
16614
- tracker.start("entity_embeddings", { files: batch.events.length });
17252
+ tracker.start("entity_embeddings", { files: filteredEvents.length });
16615
17253
  let entEmbUpdated = 0;
16616
17254
  const entEmbNames = [];
16617
17255
  try {
16618
17256
  const allEntities = getAllEntitiesFromDb3(stateDb);
16619
- for (const event of batch.events) {
17257
+ for (const event of filteredEvents) {
16620
17258
  if (event.type === "delete" || !event.path.endsWith(".md")) continue;
16621
17259
  const matching = allEntities.filter((e) => e.path === event.path);
16622
17260
  for (const entity of matching) {
@@ -16650,10 +17288,10 @@ async function runPostIndexWork(index) {
16650
17288
  } else {
16651
17289
  tracker.skip("index_cache", "no stateDb");
16652
17290
  }
16653
- tracker.start("task_cache", { files: batch.events.length });
17291
+ tracker.start("task_cache", { files: filteredEvents.length });
16654
17292
  let taskUpdated = 0;
16655
17293
  let taskRemoved = 0;
16656
- for (const event of batch.events) {
17294
+ for (const event of filteredEvents) {
16657
17295
  try {
16658
17296
  if (event.type === "delete") {
16659
17297
  removeTaskCacheForFile(event.path);
@@ -16667,10 +17305,42 @@ async function runPostIndexWork(index) {
16667
17305
  }
16668
17306
  tracker.end({ updated: taskUpdated, removed: taskRemoved });
16669
17307
  serverLog("watcher", `Task cache: ${taskUpdated} updated, ${taskRemoved} removed`);
16670
- tracker.start("wikilink_check", { files: batch.events.length });
17308
+ tracker.start("forward_links", { files: filteredEvents.length });
17309
+ const forwardLinkResults = [];
17310
+ let totalResolved = 0;
17311
+ let totalDead = 0;
17312
+ for (const event of filteredEvents) {
17313
+ if (event.type === "delete" || !event.path.endsWith(".md")) continue;
17314
+ try {
17315
+ const links = getForwardLinksForNote(vaultIndex, event.path);
17316
+ const resolved = [];
17317
+ const dead = [];
17318
+ const seen = /* @__PURE__ */ new Set();
17319
+ for (const link of links) {
17320
+ const name = link.target;
17321
+ if (seen.has(name.toLowerCase())) continue;
17322
+ seen.add(name.toLowerCase());
17323
+ if (link.exists) resolved.push(name);
17324
+ else dead.push(name);
17325
+ }
17326
+ if (resolved.length > 0 || dead.length > 0) {
17327
+ forwardLinkResults.push({ file: event.path, resolved, dead });
17328
+ }
17329
+ totalResolved += resolved.length;
17330
+ totalDead += dead.length;
17331
+ } catch {
17332
+ }
17333
+ }
17334
+ tracker.end({
17335
+ total_resolved: totalResolved,
17336
+ total_dead: totalDead,
17337
+ links: forwardLinkResults
17338
+ });
17339
+ serverLog("watcher", `Forward links: ${totalResolved} resolved, ${totalDead} dead`);
17340
+ tracker.start("wikilink_check", { files: filteredEvents.length });
16671
17341
  const trackedLinks = [];
16672
17342
  if (stateDb) {
16673
- for (const event of batch.events) {
17343
+ for (const event of filteredEvents) {
16674
17344
  if (event.type === "delete" || !event.path.endsWith(".md")) continue;
16675
17345
  try {
16676
17346
  const apps = getTrackedApplications(stateDb, event.path);
@@ -16681,13 +17351,13 @@ async function runPostIndexWork(index) {
16681
17351
  }
16682
17352
  tracker.end({ tracked: trackedLinks });
16683
17353
  serverLog("watcher", `Wikilink check: ${trackedLinks.reduce((s, t) => s + t.entities.length, 0)} tracked links in ${trackedLinks.length} files`);
16684
- tracker.start("implicit_feedback", { files: batch.events.length });
17354
+ tracker.start("implicit_feedback", { files: filteredEvents.length });
16685
17355
  const feedbackResults = [];
16686
17356
  if (stateDb) {
16687
- for (const event of batch.events) {
17357
+ for (const event of filteredEvents) {
16688
17358
  if (event.type === "delete" || !event.path.endsWith(".md")) continue;
16689
17359
  try {
16690
- const content = await fs30.readFile(path29.join(vaultPath, event.path), "utf-8");
17360
+ const content = await fs30.readFile(path30.join(vaultPath, event.path), "utf-8");
16691
17361
  const removed = processImplicitFeedback(stateDb, event.path, content);
16692
17362
  for (const entity of removed) feedbackResults.push({ entity, file: event.path });
16693
17363
  } catch {
@@ -16704,12 +17374,12 @@ async function runPostIndexWork(index) {
16704
17374
  trigger: "watcher",
16705
17375
  duration_ms: duration,
16706
17376
  note_count: vaultIndex.notes.size,
16707
- files_changed: batch.events.length,
17377
+ files_changed: filteredEvents.length,
16708
17378
  changed_paths: changedPaths,
16709
17379
  steps: tracker.steps
16710
17380
  });
16711
17381
  }
16712
- serverLog("watcher", `Batch complete: ${batch.events.length} files, ${duration}ms, ${tracker.steps.length} steps`);
17382
+ serverLog("watcher", `Batch complete: ${filteredEvents.length} files, ${duration}ms, ${tracker.steps.length} steps`);
16713
17383
  } catch (err) {
16714
17384
  setIndexState("error");
16715
17385
  setIndexError(err instanceof Error ? err : new Error(String(err)));
@@ -16719,7 +17389,7 @@ async function runPostIndexWork(index) {
16719
17389
  trigger: "watcher",
16720
17390
  duration_ms: duration,
16721
17391
  success: false,
16722
- files_changed: batch.events.length,
17392
+ files_changed: filteredEvents.length,
16723
17393
  changed_paths: changedPaths,
16724
17394
  error: err instanceof Error ? err.message : String(err),
16725
17395
  steps: tracker.steps