@velvetmonkey/flywheel-memory 2.0.134 → 2.0.136

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 +268 -33
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -3303,8 +3303,10 @@ __export(wikilinks_exports, {
3303
3303
  initializeEntityIndex: () => initializeEntityIndex,
3304
3304
  isEntityIndexReady: () => isEntityIndexReady,
3305
3305
  isLikelyArticleTitle: () => isLikelyArticleTitle,
3306
+ isValidWikilinkText: () => isValidWikilinkText,
3306
3307
  maybeApplyWikilinks: () => maybeApplyWikilinks,
3307
3308
  processWikilinks: () => processWikilinks,
3309
+ sanitizeWikilinks: () => sanitizeWikilinks,
3308
3310
  setCooccurrenceIndex: () => setCooccurrenceIndex,
3309
3311
  setWikilinkConfig: () => setWikilinkConfig,
3310
3312
  setWriteStateDb: () => setWriteStateDb,
@@ -3473,6 +3475,35 @@ function sortEntitiesByPriority(entities, notePath) {
3473
3475
  return priorityB - priorityA;
3474
3476
  });
3475
3477
  }
3478
+ function isValidWikilinkText(text) {
3479
+ const target = text.includes("|") ? text.split("|")[0] : text;
3480
+ if (target !== target.trim()) return false;
3481
+ const trimmed = target.trim();
3482
+ if (trimmed.length === 0) return false;
3483
+ if (/[?!;]/.test(trimmed)) return false;
3484
+ if (/[,.]$/.test(trimmed)) return false;
3485
+ if (trimmed.includes(">")) return false;
3486
+ if (/^[*#\-]/.test(trimmed)) return false;
3487
+ if (trimmed.length > 60) return false;
3488
+ const words = trimmed.split(/\s+/);
3489
+ if (words.length > 5) return false;
3490
+ if (words.length > 3 && trimmed === trimmed.toLowerCase()) return false;
3491
+ if (words.length > 2 && /\w'\w/.test(trimmed)) return false;
3492
+ if (words.length > 3 && /(?:ing|tion|ment|ness|ould|ould|ight)$/i.test(words[words.length - 1])) return false;
3493
+ return true;
3494
+ }
3495
+ function sanitizeWikilinks(content) {
3496
+ const removed = [];
3497
+ const sanitized = content.replace(/\[\[([^\]]+?)\]\]/g, (fullMatch, inner) => {
3498
+ if (isValidWikilinkText(inner)) {
3499
+ return fullMatch;
3500
+ }
3501
+ removed.push(inner);
3502
+ const display = inner.includes("|") ? inner.split("|")[1] : inner;
3503
+ return display;
3504
+ });
3505
+ return { content: sanitized, removed };
3506
+ }
3476
3507
  function processWikilinks(content, notePath, existingContent) {
3477
3508
  if (!isEntityIndexReady() || !entityIndex) {
3478
3509
  return {
@@ -3514,8 +3545,9 @@ function processWikilinks(content, notePath, existingContent) {
3514
3545
  caseInsensitive: true,
3515
3546
  alreadyLinked: step1LinkedEntities
3516
3547
  });
3548
+ const wordCount = content.split(/\s+/).length;
3517
3549
  const cfg = getConfig();
3518
- const implicitEnabled = cfg?.implicit_detection !== false;
3550
+ const implicitEnabled = cfg?.implicit_detection !== false && wordCount <= 500;
3519
3551
  const validPatterns = new Set(ALL_IMPLICIT_PATTERNS);
3520
3552
  const implicitPatterns = cfg?.implicit_patterns?.length ? cfg.implicit_patterns.filter((p) => validPatterns.has(p)) : [...ALL_IMPLICIT_PATTERNS];
3521
3553
  const implicitMatches = detectImplicitEntities(result.content, {
@@ -3551,23 +3583,22 @@ function processWikilinks(content, notePath, existingContent) {
3551
3583
  }
3552
3584
  }
3553
3585
  newImplicits = nonOverlapping;
3586
+ let finalContent = result.content;
3587
+ let implicitEntities;
3554
3588
  if (newImplicits.length > 0) {
3555
- let processedContent = result.content;
3556
3589
  for (let i = newImplicits.length - 1; i >= 0; i--) {
3557
3590
  const m = newImplicits[i];
3558
- processedContent = processedContent.slice(0, m.start) + `[[${m.text}]]` + processedContent.slice(m.end);
3591
+ finalContent = finalContent.slice(0, m.start) + `[[${m.text}]]` + finalContent.slice(m.end);
3559
3592
  }
3560
- return {
3561
- content: processedContent,
3562
- linksAdded: resolved.linksAdded + result.linksAdded + newImplicits.length,
3563
- linkedEntities: [...resolved.linkedEntities, ...result.linkedEntities],
3564
- implicitEntities: newImplicits.map((m) => m.text)
3565
- };
3593
+ implicitEntities = newImplicits.map((m) => m.text);
3566
3594
  }
3595
+ const { content: sanitizedContent, removed } = sanitizeWikilinks(finalContent);
3596
+ const totalLinksAdded = resolved.linksAdded + result.linksAdded + (newImplicits.length - removed.length);
3567
3597
  return {
3568
- content: result.content,
3569
- linksAdded: resolved.linksAdded + result.linksAdded,
3570
- linkedEntities: [...resolved.linkedEntities, ...result.linkedEntities]
3598
+ content: sanitizedContent,
3599
+ linksAdded: Math.max(0, totalLinksAdded),
3600
+ linkedEntities: [...resolved.linkedEntities, ...result.linkedEntities],
3601
+ ...implicitEntities ? { implicitEntities } : {}
3571
3602
  };
3572
3603
  }
3573
3604
  function maybeApplyWikilinks(content, skipWikilinks, notePath, existingContent) {
@@ -4545,6 +4576,125 @@ var init_wikilinks = __esm({
4545
4576
  }
4546
4577
  });
4547
4578
 
4579
+ // src/core/write/proactiveQueue.ts
4580
+ var proactiveQueue_exports = {};
4581
+ __export(proactiveQueue_exports, {
4582
+ drainProactiveQueue: () => drainProactiveQueue,
4583
+ enqueueProactiveSuggestions: () => enqueueProactiveSuggestions,
4584
+ expireStaleEntries: () => expireStaleEntries
4585
+ });
4586
+ function enqueueProactiveSuggestions(stateDb2, entries) {
4587
+ if (entries.length === 0) return 0;
4588
+ const now = Date.now();
4589
+ const expiresAt = now + QUEUE_TTL_MS;
4590
+ const upsert = stateDb2.db.prepare(`
4591
+ INSERT INTO proactive_queue (note_path, entity, score, confidence, queued_at, expires_at, status)
4592
+ VALUES (?, ?, ?, ?, ?, ?, 'pending')
4593
+ ON CONFLICT(note_path, entity) DO UPDATE SET
4594
+ score = CASE WHEN excluded.score > proactive_queue.score THEN excluded.score ELSE proactive_queue.score END,
4595
+ confidence = excluded.confidence,
4596
+ queued_at = excluded.queued_at,
4597
+ expires_at = excluded.expires_at,
4598
+ status = 'pending'
4599
+ WHERE proactive_queue.status = 'pending'
4600
+ `);
4601
+ let enqueued = 0;
4602
+ for (const entry of entries) {
4603
+ try {
4604
+ const result = upsert.run(entry.notePath, entry.entity, entry.score, entry.confidence, now, expiresAt);
4605
+ if (result.changes > 0) enqueued++;
4606
+ } catch {
4607
+ }
4608
+ }
4609
+ return enqueued;
4610
+ }
4611
+ async function drainProactiveQueue(stateDb2, vaultPath2, currentBatchPaths, config, applyFn) {
4612
+ const result = {
4613
+ applied: [],
4614
+ expired: 0,
4615
+ skippedActiveEdit: 0,
4616
+ skippedMtimeGuard: 0,
4617
+ skippedDailyCap: 0
4618
+ };
4619
+ result.expired = expireStaleEntries(stateDb2);
4620
+ const now = Date.now();
4621
+ const pending = stateDb2.db.prepare(`
4622
+ SELECT note_path, entity, score, confidence
4623
+ FROM proactive_queue
4624
+ WHERE status = 'pending' AND expires_at > ?
4625
+ ORDER BY note_path, score DESC
4626
+ `).all(now);
4627
+ if (pending.length === 0) return result;
4628
+ const byFile = /* @__PURE__ */ new Map();
4629
+ for (const row of pending) {
4630
+ if (!byFile.has(row.note_path)) byFile.set(row.note_path, []);
4631
+ byFile.get(row.note_path).push({ entity: row.entity, score: row.score, confidence: row.confidence });
4632
+ }
4633
+ const markApplied = stateDb2.db.prepare(
4634
+ `UPDATE proactive_queue SET status = 'applied', applied_at = ? WHERE note_path = ? AND entity = ? AND status = 'pending'`
4635
+ );
4636
+ const todayMidnight = /* @__PURE__ */ new Date();
4637
+ todayMidnight.setHours(0, 0, 0, 0);
4638
+ const todayStr = todayMidnight.toISOString().slice(0, 10);
4639
+ const countTodayApplied = stateDb2.db.prepare(
4640
+ `SELECT COUNT(*) as cnt FROM wikilink_applications WHERE note_path = ? AND applied_at >= ?`
4641
+ );
4642
+ for (const [filePath, suggestions] of byFile) {
4643
+ if (currentBatchPaths.has(filePath)) {
4644
+ result.skippedActiveEdit += suggestions.length;
4645
+ continue;
4646
+ }
4647
+ const todayCount = countTodayApplied.get(filePath, todayStr).cnt;
4648
+ if (todayCount >= config.maxPerDay) {
4649
+ result.skippedDailyCap += suggestions.length;
4650
+ for (const s of suggestions) {
4651
+ try {
4652
+ stateDb2.db.prepare(
4653
+ `UPDATE proactive_queue SET status = 'expired' WHERE note_path = ? AND entity = ? AND status = 'pending'`
4654
+ ).run(filePath, s.entity);
4655
+ } catch {
4656
+ }
4657
+ }
4658
+ continue;
4659
+ }
4660
+ const remaining = config.maxPerDay - todayCount;
4661
+ const capped = suggestions.slice(0, remaining);
4662
+ try {
4663
+ const applyResult = await applyFn(filePath, vaultPath2, capped, config);
4664
+ if (applyResult.applied.length > 0) {
4665
+ result.applied.push({ file: filePath, entities: applyResult.applied });
4666
+ const appliedAt = Date.now();
4667
+ for (const entity of applyResult.applied) {
4668
+ try {
4669
+ markApplied.run(appliedAt, filePath, entity);
4670
+ } catch {
4671
+ }
4672
+ }
4673
+ }
4674
+ if (applyResult.applied.length === 0 && applyResult.skipped.length > 0) {
4675
+ result.skippedMtimeGuard += applyResult.skipped.length;
4676
+ }
4677
+ } catch (e) {
4678
+ serverLog("watcher", `Proactive drain: error applying to ${filePath}: ${e}`, "error");
4679
+ }
4680
+ }
4681
+ return result;
4682
+ }
4683
+ function expireStaleEntries(stateDb2) {
4684
+ const result = stateDb2.db.prepare(
4685
+ `UPDATE proactive_queue SET status = 'expired' WHERE status = 'pending' AND expires_at <= ?`
4686
+ ).run(Date.now());
4687
+ return result.changes;
4688
+ }
4689
+ var QUEUE_TTL_MS;
4690
+ var init_proactiveQueue = __esm({
4691
+ "src/core/write/proactiveQueue.ts"() {
4692
+ "use strict";
4693
+ init_serverLog();
4694
+ QUEUE_TTL_MS = 60 * 60 * 1e3;
4695
+ }
4696
+ });
4697
+
4548
4698
  // src/core/write/constants.ts
4549
4699
  function estimateTokens(content) {
4550
4700
  const str = typeof content === "string" ? content : JSON.stringify(content);
@@ -7908,6 +8058,7 @@ async function exportHubScores(vaultIndex2, stateDb2) {
7908
8058
  init_recency();
7909
8059
  init_cooccurrence();
7910
8060
  init_wikilinks();
8061
+ init_proactiveQueue();
7911
8062
  init_retrievalCooccurrence();
7912
8063
  init_embeddings();
7913
8064
 
@@ -8305,6 +8456,7 @@ var PipelineRunner = class {
8305
8456
  async run() {
8306
8457
  const { p, tracker } = this;
8307
8458
  try {
8459
+ await runStep("drain_proactive_queue", tracker, {}, () => this.drainQueue());
8308
8460
  await this.indexRebuild();
8309
8461
  this.noteMoves();
8310
8462
  await this.entityScan();
@@ -8929,33 +9081,61 @@ var PipelineRunner = class {
8929
9081
  serverLog("watcher", `Suggestion scoring: ${this.suggestionResults.length} files scored`);
8930
9082
  }
8931
9083
  }
8932
- // ── Step 12.5: Proactive linking ──────────────────────────────────
9084
+ // ── Step 0.5: Drain proactive queue ──────────────────────────────
9085
+ async drainQueue() {
9086
+ const { p } = this;
9087
+ if (!p.sd || p.flywheelConfig?.proactive_linking === false) {
9088
+ return { skipped: true };
9089
+ }
9090
+ const currentBatchPaths = new Set(p.events.map((e) => e.path));
9091
+ const result = await drainProactiveQueue(
9092
+ p.sd,
9093
+ p.vp,
9094
+ currentBatchPaths,
9095
+ {
9096
+ minScore: p.flywheelConfig?.proactive_min_score ?? 20,
9097
+ maxPerFile: p.flywheelConfig?.proactive_max_per_file ?? 3,
9098
+ maxPerDay: p.flywheelConfig?.proactive_max_per_day ?? 10
9099
+ },
9100
+ applyProactiveSuggestions
9101
+ );
9102
+ const totalApplied = result.applied.reduce((s, r) => s + r.entities.length, 0);
9103
+ if (totalApplied > 0) {
9104
+ serverLog("watcher", `Proactive drain: applied ${totalApplied} links in ${result.applied.length} files`);
9105
+ }
9106
+ return {
9107
+ applied: result.applied,
9108
+ total_applied: totalApplied,
9109
+ expired: result.expired,
9110
+ skipped_active: result.skippedActiveEdit,
9111
+ skipped_mtime: result.skippedMtimeGuard,
9112
+ skipped_daily_cap: result.skippedDailyCap
9113
+ };
9114
+ }
9115
+ // ── Step 12.5: Proactive enqueue ───────────────────────────────────
8933
9116
  async proactiveLinking() {
8934
9117
  const { p, tracker } = this;
8935
9118
  if (p.flywheelConfig?.proactive_linking === false || this.suggestionResults.length === 0) return;
8936
- tracker.start("proactive_linking", { files: this.suggestionResults.length });
9119
+ if (!p.sd) return;
9120
+ tracker.start("proactive_enqueue", { files: this.suggestionResults.length });
8937
9121
  try {
8938
- const proactiveResults = [];
9122
+ const minScore = p.flywheelConfig?.proactive_min_score ?? 20;
9123
+ const maxPerFile = p.flywheelConfig?.proactive_max_per_file ?? 3;
9124
+ const entries = [];
8939
9125
  for (const { file, top } of this.suggestionResults) {
8940
- try {
8941
- const result = await applyProactiveSuggestions(file, p.vp, top, {
8942
- minScore: p.flywheelConfig?.proactive_min_score ?? 20,
8943
- maxPerFile: p.flywheelConfig?.proactive_max_per_file ?? 3
8944
- });
8945
- if (result.applied.length > 0) {
8946
- proactiveResults.push({ file, applied: result.applied });
8947
- }
8948
- } catch {
9126
+ const candidates = top.filter((s) => s.score >= minScore && s.confidence === "high").slice(0, maxPerFile);
9127
+ for (const c of candidates) {
9128
+ entries.push({ notePath: file, entity: c.entity, score: c.score, confidence: c.confidence });
8949
9129
  }
8950
9130
  }
8951
- const totalApplied = proactiveResults.reduce((s, r) => s + r.applied.length, 0);
8952
- tracker.end({ files_modified: proactiveResults.length, total_applied: totalApplied, results: proactiveResults });
8953
- if (totalApplied > 0) {
8954
- serverLog("watcher", `Proactive linking: ${totalApplied} links in ${proactiveResults.length} files`);
9131
+ const enqueued = enqueueProactiveSuggestions(p.sd, entries);
9132
+ tracker.end({ enqueued, total_candidates: entries.length });
9133
+ if (enqueued > 0) {
9134
+ serverLog("watcher", `Proactive enqueue: ${enqueued} suggestions queued for deferred application`);
8955
9135
  }
8956
9136
  } catch (e) {
8957
9137
  tracker.end({ error: String(e) });
8958
- serverLog("watcher", `Proactive linking failed: ${e}`, "error");
9138
+ serverLog("watcher", `Proactive enqueue failed: ${e}`, "error");
8959
9139
  }
8960
9140
  }
8961
9141
  // ── Step 13: Tag scan ─────────────────────────────────────────────
@@ -10081,6 +10261,45 @@ function buildGraphData(index, stateDb2, options) {
10081
10261
  }
10082
10262
  }
10083
10263
  }
10264
+ if (options.center_entity) {
10265
+ const centerLower = options.center_entity.toLowerCase();
10266
+ const maxDepth = options.depth ?? 1;
10267
+ const centerNode = nodes.find((n) => n.label.toLowerCase() === centerLower);
10268
+ if (centerNode) {
10269
+ const adj = /* @__PURE__ */ new Map();
10270
+ for (const edge of edges) {
10271
+ if (!adj.has(edge.source)) adj.set(edge.source, /* @__PURE__ */ new Set());
10272
+ if (!adj.has(edge.target)) adj.set(edge.target, /* @__PURE__ */ new Set());
10273
+ adj.get(edge.source).add(edge.target);
10274
+ adj.get(edge.target).add(edge.source);
10275
+ }
10276
+ const reachable = /* @__PURE__ */ new Set();
10277
+ const queue = [{ id: centerNode.id, depth: 0 }];
10278
+ reachable.add(centerNode.id);
10279
+ while (queue.length > 0) {
10280
+ const { id, depth } = queue.shift();
10281
+ if (depth >= maxDepth) continue;
10282
+ for (const neighbor of adj.get(id) ?? []) {
10283
+ if (!reachable.has(neighbor)) {
10284
+ reachable.add(neighbor);
10285
+ queue.push({ id: neighbor, depth: depth + 1 });
10286
+ }
10287
+ }
10288
+ }
10289
+ const filteredNodes = nodes.filter((n) => reachable.has(n.id));
10290
+ const filteredEdges = edges.filter((e) => reachable.has(e.source) && reachable.has(e.target));
10291
+ return {
10292
+ nodes: filteredNodes,
10293
+ edges: filteredEdges,
10294
+ metadata: {
10295
+ note_count: filteredNodes.filter((n) => n.type === "note").length,
10296
+ entity_count: filteredNodes.filter((n) => n.type === "entity").length,
10297
+ edge_count: filteredEdges.length,
10298
+ exported_at: (/* @__PURE__ */ new Date()).toISOString()
10299
+ }
10300
+ };
10301
+ }
10302
+ }
10084
10303
  return {
10085
10304
  nodes,
10086
10305
  edges,
@@ -10152,17 +10371,19 @@ function toGraphML(data) {
10152
10371
  function registerGraphExportTools(server2, getIndex, getVaultPath, getStateDb2) {
10153
10372
  server2.tool(
10154
10373
  "export_graph",
10155
- "Export the vault knowledge graph as GraphML (for Gephi/yEd/Cytoscape) or JSON. Includes notes, entities, wikilinks, edge weights, and co-occurrence relationships. Use the output with graph visualization tools to explore your vault structure.",
10374
+ 'Export the vault knowledge graph as GraphML (for Gephi/yEd/Cytoscape) or JSON. Includes notes, entities, wikilinks, edge weights, and co-occurrence relationships. Use center_entity + depth for focused ego-network exports (e.g., "everything within 2 hops of Acme Corp").',
10156
10375
  {
10157
10376
  format: z2.enum(["graphml", "json"]).default("graphml").describe('Output format: "graphml" for graph tools (Gephi, yEd, Cytoscape), "json" for programmatic use'),
10158
10377
  include_cooccurrence: z2.boolean().default(true).describe("Include co-occurrence edges between entities"),
10159
- min_edge_weight: z2.number().default(0).describe("Minimum edge weight threshold (filters weighted edges)")
10378
+ min_edge_weight: z2.number().default(0).describe("Minimum edge weight threshold (filters weighted edges)"),
10379
+ center_entity: z2.string().optional().describe("Center the export on this entity (ego network). Only includes nodes within `depth` hops."),
10380
+ depth: z2.number().default(1).describe("Hops from center_entity to include (default 1). Ignored without center_entity.")
10160
10381
  },
10161
- async ({ format, include_cooccurrence, min_edge_weight }) => {
10382
+ async ({ format, include_cooccurrence, min_edge_weight, center_entity, depth }) => {
10162
10383
  requireIndex();
10163
10384
  const index = getIndex();
10164
10385
  const stateDb2 = getStateDb2?.() ?? null;
10165
- const data = buildGraphData(index, stateDb2, { include_cooccurrence, min_edge_weight });
10386
+ const data = buildGraphData(index, stateDb2, { include_cooccurrence, min_edge_weight, center_entity, depth });
10166
10387
  let output;
10167
10388
  if (format === "json") {
10168
10389
  output = JSON.stringify(data, null, 2);
@@ -24236,6 +24457,9 @@ async function runPostIndexWork(ctx) {
24236
24457
  const renameWikilinkApplications = sd.db.prepare(
24237
24458
  "UPDATE wikilink_applications SET note_path = ? WHERE note_path = ?"
24238
24459
  );
24460
+ const renameProactiveQueue = sd.db.prepare(
24461
+ "UPDATE proactive_queue SET note_path = ? WHERE note_path = ? AND status = 'pending'"
24462
+ );
24239
24463
  for (const rename of batchRenames) {
24240
24464
  const oldFolder = rename.oldPath.includes("/") ? rename.oldPath.split("/").slice(0, -1).join("/") : "";
24241
24465
  const newFolder = rename.newPath.includes("/") ? rename.newPath.split("/").slice(0, -1).join("/") : "";
@@ -24244,6 +24468,7 @@ async function runPostIndexWork(ctx) {
24244
24468
  renameNoteTags.run(rename.newPath, rename.oldPath);
24245
24469
  renameNoteLinkHistory.run(rename.newPath, rename.oldPath);
24246
24470
  renameWikilinkApplications.run(rename.newPath, rename.oldPath);
24471
+ renameProactiveQueue.run(rename.newPath, rename.oldPath);
24247
24472
  const oldHash = lastContentHashes.get(rename.oldPath);
24248
24473
  if (oldHash !== void 0) {
24249
24474
  lastContentHashes.set(rename.newPath, oldHash);
@@ -24313,6 +24538,16 @@ async function runPostIndexWork(ctx) {
24313
24538
  }
24314
24539
  }
24315
24540
  }
24541
+ if (sd) {
24542
+ try {
24543
+ const { expireStaleEntries: expireStaleEntries2 } = await Promise.resolve().then(() => (init_proactiveQueue(), proactiveQueue_exports));
24544
+ const expired = expireStaleEntries2(sd);
24545
+ if (expired > 0) {
24546
+ serverLog("watcher", `Startup: expired ${expired} stale proactive queue entries`);
24547
+ }
24548
+ } catch {
24549
+ }
24550
+ }
24316
24551
  watcher.start();
24317
24552
  serverLog("watcher", "File watcher started");
24318
24553
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.0.134",
3
+ "version": "2.0.136",
4
4
  "description": "MCP server that gives Claude full read/write access to your Obsidian vault. Select from 69 tools for search, backlinks, graph queries, mutations, agent memory, and hybrid semantic search.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@modelcontextprotocol/sdk": "^1.25.1",
56
- "@velvetmonkey/vault-core": "2.0.134",
56
+ "@velvetmonkey/vault-core": "^2.0.136",
57
57
  "better-sqlite3": "^11.0.0",
58
58
  "chokidar": "^4.0.0",
59
59
  "gray-matter": "^4.0.3",