@velvetmonkey/flywheel-memory 2.0.143 → 2.0.144

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 +74 -33
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1883,12 +1883,12 @@ function computePosteriorMean(weightedCorrect, weightedFp) {
1883
1883
  const beta_ = PRIOR_BETA + weightedFp;
1884
1884
  return alpha / (alpha + beta_);
1885
1885
  }
1886
- function recordFeedback(stateDb2, entity, context, notePath, correct, confidence = 1) {
1886
+ function recordFeedback(stateDb2, entity, context, notePath, correct, confidence = 1, matchedTerm) {
1887
1887
  try {
1888
- console.error(`[Flywheel] recordFeedback: entity="${entity}" context="${context}" notePath="${notePath}" correct=${correct}`);
1888
+ console.error(`[Flywheel] recordFeedback: entity="${entity}" term="${matchedTerm ?? entity}" context="${context}" notePath="${notePath}" correct=${correct}`);
1889
1889
  const result = stateDb2.db.prepare(
1890
- "INSERT INTO wikilink_feedback (entity, context, note_path, correct, confidence) VALUES (?, ?, ?, ?, ?)"
1891
- ).run(entity, context, notePath, correct ? 1 : 0, confidence);
1890
+ "INSERT INTO wikilink_feedback (entity, context, note_path, correct, confidence, matched_term) VALUES (?, ?, ?, ?, ?, ?)"
1891
+ ).run(entity, context, notePath, correct ? 1 : 0, confidence, matchedTerm ?? null);
1892
1892
  console.error(`[Flywheel] recordFeedback: inserted id=${result.lastInsertRowid}`);
1893
1893
  } catch (e) {
1894
1894
  console.error(`[Flywheel] recordFeedback failed for entity="${entity}": ${e}`);
@@ -2173,9 +2173,10 @@ function getAllSuppressionPenalties(stateDb2, now) {
2173
2173
  }
2174
2174
  function trackWikilinkApplications(stateDb2, notePath, entities) {
2175
2175
  const upsert = stateDb2.db.prepare(`
2176
- INSERT INTO wikilink_applications (entity, note_path, applied_at, status)
2177
- VALUES (?, ?, datetime('now'), 'applied')
2176
+ INSERT INTO wikilink_applications (entity, note_path, matched_term, applied_at, status)
2177
+ VALUES (?, ?, ?, datetime('now'), 'applied')
2178
2178
  ON CONFLICT(entity, note_path) DO UPDATE SET
2179
+ matched_term = COALESCE(?, matched_term),
2179
2180
  applied_at = datetime('now'),
2180
2181
  status = 'applied'
2181
2182
  `);
@@ -2183,10 +2184,12 @@ function trackWikilinkApplications(stateDb2, notePath, entities) {
2183
2184
  `SELECT name FROM entities WHERE LOWER(name) = LOWER(?) LIMIT 1`
2184
2185
  );
2185
2186
  const transaction = stateDb2.db.transaction(() => {
2186
- for (const entity of entities) {
2187
- const row = lookupCanonical.get(entity);
2188
- const canonicalName = row?.name ?? entity;
2189
- upsert.run(canonicalName, notePath);
2187
+ for (const item of entities) {
2188
+ const entityName = typeof item === "string" ? item : item.entity;
2189
+ const matchedTerm = typeof item === "string" ? null : item.matchedTerm ?? null;
2190
+ const row = lookupCanonical.get(entityName);
2191
+ const canonicalName = row?.name ?? entityName;
2192
+ upsert.run(canonicalName, notePath, matchedTerm, matchedTerm);
2190
2193
  }
2191
2194
  });
2192
2195
  transaction();
@@ -2199,7 +2202,7 @@ function getTrackedApplications(stateDb2, notePath) {
2199
2202
  }
2200
2203
  function getTrackedApplicationsWithTime(stateDb2, notePath) {
2201
2204
  return stateDb2.db.prepare(
2202
- `SELECT entity, applied_at FROM wikilink_applications WHERE note_path = ? AND status = 'applied'`
2205
+ `SELECT entity, applied_at, matched_term FROM wikilink_applications WHERE note_path = ? AND status = 'applied'`
2203
2206
  ).all(notePath);
2204
2207
  }
2205
2208
  function computeImplicitRemovalConfidence(appliedAt) {
@@ -2270,10 +2273,10 @@ function processImplicitFeedback(stateDb2, notePath, currentContent) {
2270
2273
  `UPDATE wikilink_applications SET status = 'removed' WHERE entity = ? AND note_path = ?`
2271
2274
  );
2272
2275
  const transaction = stateDb2.db.transaction(() => {
2273
- for (const { entity, applied_at } of trackedWithTime) {
2276
+ for (const { entity, applied_at, matched_term } of trackedWithTime) {
2274
2277
  if (!currentLinks.has(entity.toLowerCase())) {
2275
2278
  const confidence = computeImplicitRemovalConfidence(applied_at);
2276
- recordFeedback(stateDb2, entity, "implicit:removed", notePath, false, confidence);
2279
+ recordFeedback(stateDb2, entity, "implicit:removed", notePath, false, confidence, matched_term ?? void 0);
2277
2280
  markRemoved.run(entity, notePath);
2278
2281
  removed.push(entity);
2279
2282
  }
@@ -2288,12 +2291,12 @@ function processImplicitFeedback(stateDb2, notePath, currentContent) {
2288
2291
  `SELECT MAX(created_at) as last FROM wikilink_feedback
2289
2292
  WHERE entity = ? COLLATE NOCASE AND context = 'implicit:survived' AND note_path = ?`
2290
2293
  );
2291
- for (const { entity } of trackedWithTime) {
2294
+ for (const { entity, matched_term } of trackedWithTime) {
2292
2295
  if (currentLinks.has(entity.toLowerCase())) {
2293
2296
  const lastSurvival = getLastSurvival.get(entity, notePath);
2294
2297
  const lastAt = lastSurvival?.last ? new Date(lastSurvival.last).getTime() : 0;
2295
2298
  if (Date.now() - lastAt > SURVIVAL_COOLDOWN_MS) {
2296
- recordFeedback(stateDb2, entity, "implicit:survived", notePath, true, 0.8);
2299
+ recordFeedback(stateDb2, entity, "implicit:survived", notePath, true, 0.8, matched_term ?? void 0);
2297
2300
  }
2298
2301
  }
2299
2302
  }
@@ -3688,17 +3691,25 @@ function isLikelyArticleTitle(name) {
3688
3691
  }
3689
3692
  function getCrossFolderBoost(entityPath, notePath) {
3690
3693
  if (!entityPath || !notePath) return 0;
3691
- const entityFolder = entityPath.split("/")[0];
3692
- const noteFolder = notePath.split("/")[0];
3693
- if (entityFolder && noteFolder && entityFolder !== noteFolder) {
3694
- return CROSS_FOLDER_BOOST;
3694
+ const entityParts = entityPath.split("/");
3695
+ const noteParts = notePath.split("/");
3696
+ const entityFolder = entityParts[0];
3697
+ const noteFolder = noteParts[0];
3698
+ if (!entityFolder || !noteFolder) return 0;
3699
+ if (entityFolder === noteFolder) {
3700
+ if (entityParts[1] && noteParts[1] && entityParts[1] === noteParts[1]) {
3701
+ return 3;
3702
+ }
3703
+ return 2;
3695
3704
  }
3696
- return 0;
3705
+ if (GENERIC_FOLDERS.has(entityFolder)) return 0;
3706
+ if (GENERIC_FOLDERS.has(noteFolder)) return -1;
3707
+ return -3;
3697
3708
  }
3698
3709
  function getHubBoost(entity) {
3699
3710
  const hubScore = entity.hubScore ?? 0;
3700
3711
  if (hubScore <= 0) return 0;
3701
- return Math.min(Math.round(Math.log2(hubScore) * 10) / 10, 6);
3712
+ return Math.min(Math.round(Math.log2(hubScore) * 6) / 10, 4);
3702
3713
  }
3703
3714
  function getNoteContext(notePath) {
3704
3715
  const lower = notePath.toLowerCase();
@@ -3955,7 +3966,7 @@ async function suggestRelatedLinks(content, options = {}) {
3955
3966
  const recencyBoostVal = hasContentOverlap && !disabled.has("recency") ? recencyIndex ? getRecencyBoost(entityName, recencyIndex) : 0 : 0;
3956
3967
  const rawCrossFolderBoost = disabled.has("cross_folder") ? 0 : notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
3957
3968
  const rawHubBoost = disabled.has("hub_boost") ? 0 : getHubBoost(entity);
3958
- const hubBoost = hasContentOverlap ? rawHubBoost : Math.min(rawHubBoost, 4);
3969
+ const hubBoost = hasContentOverlap ? rawHubBoost : Math.min(rawHubBoost, 2);
3959
3970
  const crossFolderBoost = hasContentOverlap ? rawCrossFolderBoost : Math.min(rawCrossFolderBoost, 2);
3960
3971
  const feedbackAdj = disabled.has("feedback") ? 0 : feedbackBoosts.get(entityName) ?? 0;
3961
3972
  const edgeWeightBoost = disabled.has("edge_weight") ? 0 : getEdgeWeightBoostScore(entityName, edgeWeightMap);
@@ -4112,11 +4123,37 @@ async function suggestRelatedLinks(content, options = {}) {
4112
4123
  return emptyResult;
4113
4124
  }
4114
4125
  const MAX_SUFFIX_ENTRIES = 3;
4126
+ const MAX_SUFFIX_PER_CATEGORY = 2;
4127
+ const MAX_SUFFIX_PER_FOLDER = 2;
4128
+ const MAX_SUFFIX_APPEARANCES = 5;
4115
4129
  const MIN_SUFFIX_SCORE = noteContext === "daily" ? 8 : 12;
4116
4130
  const MIN_SUFFIX_CONTENT = noteContext === "daily" ? 2 : 3;
4117
- const suffixEntries = topEntries.filter(
4118
- (e) => e.score >= MIN_SUFFIX_SCORE && (e.breakdown.contentMatch >= MIN_SUFFIX_CONTENT || e.breakdown.cooccurrenceBoost >= MIN_SUFFIX_CONTENT || (e.breakdown.semanticBoost ?? 0) >= MIN_SUFFIX_CONTENT)
4119
- ).slice(0, MAX_SUFFIX_ENTRIES);
4131
+ const suffixCandidates = topEntries.filter((e) => {
4132
+ if (e.score < MIN_SUFFIX_SCORE) return false;
4133
+ if (!(e.breakdown.contentMatch >= MIN_SUFFIX_CONTENT || e.breakdown.cooccurrenceBoost >= MIN_SUFFIX_CONTENT || (e.breakdown.semanticBoost ?? 0) >= MIN_SUFFIX_CONTENT)) return false;
4134
+ if (!disabled.has("fatigue")) {
4135
+ const escapedName = e.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4136
+ const suffixPattern = new RegExp(`\u2192 .*\\[\\[${escapedName}\\]\\]`, "g");
4137
+ const appearances = (content.match(suffixPattern) || []).length;
4138
+ if (appearances >= MAX_SUFFIX_APPEARANCES) return false;
4139
+ }
4140
+ return true;
4141
+ });
4142
+ const suffixEntries = [];
4143
+ const categoryCount = /* @__PURE__ */ new Map();
4144
+ const folderCount = /* @__PURE__ */ new Map();
4145
+ for (const c of suffixCandidates) {
4146
+ const cat = c.category ?? "other";
4147
+ const folder = c.path?.split("/")[0] ?? "";
4148
+ const catN = categoryCount.get(cat) ?? 0;
4149
+ const folderN = folderCount.get(folder) ?? 0;
4150
+ if (catN >= MAX_SUFFIX_PER_CATEGORY) continue;
4151
+ if (folderN >= MAX_SUFFIX_PER_FOLDER) continue;
4152
+ suffixEntries.push(c);
4153
+ categoryCount.set(cat, catN + 1);
4154
+ folderCount.set(folder, folderN + 1);
4155
+ if (suffixEntries.length >= MAX_SUFFIX_ENTRIES) break;
4156
+ }
4120
4157
  const suffix = suffixEntries.length > 0 ? "\u2192 " + suffixEntries.map((e) => `[[${e.name}]]`).join(", ") : "";
4121
4158
  const result = {
4122
4159
  suggestions: topSuggestions,
@@ -4357,7 +4394,7 @@ async function applyProactiveSuggestions(filePath, vaultPath2, suggestions, conf
4357
4394
  skipped: candidates.map((c) => c.entity).filter((e) => !result.linkedEntities.includes(e))
4358
4395
  };
4359
4396
  }
4360
- var moduleStateDb4, moduleConfig, ALL_IMPLICIT_PATTERNS, entityIndex, indexReady, indexError2, lastLoadedAt, cooccurrenceIndex, recencyIndex, DEFAULT_EXCLUDE_FOLDERS, SUGGESTION_PATTERN, GENERIC_WORDS, MAX_ENTITY_LENGTH, MAX_ENTITY_WORDS, ARTICLE_PATTERNS, STRICTNESS_CONFIGS, TYPE_BOOST, CROSS_FOLDER_BOOST, SEMANTIC_MIN_SIMILARITY, SEMANTIC_MAX_BOOST, CONTEXT_BOOST, MIN_SUGGESTION_SCORE, MIN_MATCH_RATIO, FULL_ALIAS_MATCH_BONUS;
4397
+ var moduleStateDb4, moduleConfig, ALL_IMPLICIT_PATTERNS, entityIndex, indexReady, indexError2, lastLoadedAt, cooccurrenceIndex, recencyIndex, DEFAULT_EXCLUDE_FOLDERS, SUGGESTION_PATTERN, GENERIC_WORDS, MAX_ENTITY_LENGTH, MAX_ENTITY_WORDS, ARTICLE_PATTERNS, STRICTNESS_CONFIGS, TYPE_BOOST, GENERIC_FOLDERS, SEMANTIC_MIN_SIMILARITY, SEMANTIC_MAX_BOOST, CONTEXT_BOOST, MIN_SUGGESTION_SCORE, MIN_MATCH_RATIO, FULL_ALIAS_MATCH_BONUS;
4361
4398
  var init_wikilinks = __esm({
4362
4399
  "src/core/write/wikilinks.ts"() {
4363
4400
  "use strict";
@@ -4547,7 +4584,7 @@ var init_wikilinks = __esm({
4547
4584
  other: 0
4548
4585
  // Unknown category
4549
4586
  };
4550
- CROSS_FOLDER_BOOST = 3;
4587
+ GENERIC_FOLDERS = /* @__PURE__ */ new Set(["daily-notes", "weekly-notes", "clippings", "templates", "new"]);
4551
4588
  SEMANTIC_MIN_SIMILARITY = 0.3;
4552
4589
  SEMANTIC_MAX_BOOST = 12;
4553
4590
  CONTEXT_BOOST = {
@@ -10868,9 +10905,9 @@ This server manages multiple vaults. Every tool has an optional "vault" paramete
10868
10905
  }
10869
10906
  parts.push(`
10870
10907
  **Frontmatter matters more than content** for Flywheel's intelligence. When creating or updating notes, always set:
10871
- - \`type:\` \u2014 drives entity categorization (person, project, technology). Without it, the category is guessed from the name alone.
10872
- - \`aliases:\` \u2014 alternative names so the entity is found when referred to differently.
10873
- - \`description:\` \u2014 one-line summary shown in search results and used by recall.
10908
+ - \`type:\` \u2014 drives entity categorization (person, project, technology). Without it, the category is guessed from the name alone and is often wrong.
10909
+ - \`aliases:\` \u2014 alternative names so the entity is found when referred to differently. Without it, the entity is invisible to searches using alternate names.
10910
+ - \`description:\` \u2014 one-line summary shown in search results and used by recall. Without it, search results and recall are degraded.
10874
10911
  - Tags \u2014 used for filtering, suggestion scoring, and schema analysis.
10875
10912
  Good frontmatter is the highest-leverage action for improving suggestions, recall, and link quality.`);
10876
10913
  if (categories.has("read")) {
@@ -10889,6 +10926,10 @@ Escalation: "search" (enriched metadata + content preview) \u2192 "get_note_stru
10889
10926
  created with the correct structure and frontmatter for this vault. Use a matching policy instead of
10890
10927
  raw write tools when one exists. Fall back to direct tools only when no policy fits.
10891
10928
 
10929
+ **Every new note should have \`type\`, \`aliases\`, and \`description\` in frontmatter** \u2014 this is what powers
10930
+ entity categorization, search ranking, and link suggestions. Notes without frontmatter are nearly invisible
10931
+ to the intelligence layer.
10932
+
10892
10933
  Write to existing notes with "vault_add_to_section". Create new notes with "vault_create_note".
10893
10934
  Update metadata with "vault_update_frontmatter". These are fallback tools \u2014 use them when no policy fits.
10894
10935
  All writes auto-link entities \u2014 no manual [[wikilinks]] needed.
@@ -18363,7 +18404,7 @@ import { z as z15 } from "zod";
18363
18404
  function registerFrontmatterTools(server2, getVaultPath) {
18364
18405
  server2.tool(
18365
18406
  "vault_update_frontmatter",
18366
- `Update frontmatter fields in a note (merge with existing). Set only_if_missing=true to only add fields that don't already exist (absorbed vault_add_frontmatter_field).
18407
+ `Update frontmatter fields in a note (merge with existing). Use this to backfill type, aliases, and description on notes missing them \u2014 these fields are critical for search and link quality. Set only_if_missing=true to only add fields that don't already exist (absorbed vault_add_frontmatter_field).
18367
18408
 
18368
18409
  Example: vault_update_frontmatter({ path: "projects/alpha.md", frontmatter: { status: "active", priority: 1 }, only_if_missing: true })`,
18369
18410
  {
@@ -18430,12 +18471,12 @@ import path26 from "path";
18430
18471
  function registerNoteTools(server2, getVaultPath, getIndex) {
18431
18472
  server2.tool(
18432
18473
  "vault_create_note",
18433
- 'Create a new note in the vault with optional frontmatter and content.\n\nExample: vault_create_note({ path: "people/Jane Smith.md", content: "# Jane Smith\\n\\nProduct manager at Acme.", frontmatter: { type: "person", company: "Acme" } })',
18474
+ 'Create a new note in the vault. Always include frontmatter with at least type, aliases, and description \u2014 these drive entity categorization, search, and link suggestions.\n\nExample: vault_create_note({ path: "people/Jane Smith.md", content: "# Jane Smith\\n\\nProduct manager at Acme.", frontmatter: { type: "person", aliases: ["Jane"], description: "Product manager at Acme", company: "Acme" } })',
18434
18475
  {
18435
18476
  path: z16.string().describe('Vault-relative path for the new note (e.g., "daily-notes/2026-01-28.md")'),
18436
18477
  content: z16.string().default("").describe("Initial content for the note"),
18437
18478
  template: z16.string().optional().describe('Vault-relative path to a template file (e.g., "templates/person.md"). Template variables {{date}} and {{title}} are substituted. Template frontmatter is merged with the frontmatter parameter (explicit values take precedence).'),
18438
- frontmatter: z16.record(z16.any()).default({}).describe("Frontmatter fields (JSON object)"),
18479
+ frontmatter: z16.record(z16.any()).default({}).describe("Frontmatter fields (JSON object). At minimum set type, aliases, and description \u2014 without these the note is nearly invisible to search and suggestions."),
18439
18480
  overwrite: z16.boolean().default(false).describe("If true, overwrite existing file"),
18440
18481
  commit: z16.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
18441
18482
  skipWikilinks: z16.boolean().default(false).describe("If true, skip auto-wikilink application (wikilinks are applied by default)"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.0.143",
3
+ "version": "2.0.144",
4
4
  "description": "MCP server that gives Claude full read/write access to your Obsidian vault. Select from 74 tools for search, backlinks, graph queries, mutations, agent memory, and hybrid semantic search.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -54,7 +54,7 @@
54
54
  "dependencies": {
55
55
  "@huggingface/transformers": "^3.8.1",
56
56
  "@modelcontextprotocol/sdk": "^1.25.1",
57
- "@velvetmonkey/vault-core": "^2.0.143",
57
+ "@velvetmonkey/vault-core": "^2.0.144",
58
58
  "better-sqlite3": "^12.0.0",
59
59
  "chokidar": "^4.0.0",
60
60
  "gray-matter": "^4.0.3",