@velvetmonkey/flywheel-memory 2.0.76 → 2.0.78

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 (3) hide show
  1. package/README.md +26 -30
  2. package/dist/index.js +154 -62
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -12,13 +12,13 @@
12
12
  [![Scale](https://img.shields.io/badge/scale-100k--line%20files%20%7C%202.5k%20entities-brightgreen.svg)](https://github.com/velvetmonkey/flywheel-memory/blob/main/docs/TESTING.md#performance-benchmarks)
13
13
  [![Tests](https://img.shields.io/badge/tests-2,456%20passed-brightgreen.svg)](https://github.com/velvetmonkey/flywheel-memory/blob/main/docs/TESTING.md)
14
14
 
15
- | | Without Flywheel | With Flywheel |
15
+ | | Grep approach | Flywheel |
16
16
  |---|---|---|
17
- | "What's overdue?" | Read every file | Indexed query, <10ms |
18
- | "What links here?" | grep every file | Pre-indexed backlink graph |
17
+ | "What's overdue?" | Grep + read matches (~500-2,000 tokens) | Indexed metadata query (~50-200 tokens) |
18
+ | "What links here?" | Grep for note name (flat list, no graph) | Pre-indexed backlink graph (<10ms) |
19
19
  | "Add a meeting note" | Raw write, no linking | Structured write + auto-wikilink |
20
- | "What should I link?" | Manual or grep | Smart scoring + semantic understanding |
21
- | Token cost | 2,000-250,000 | 50-200 |
20
+ | "What should I link?" | Not possible | 10-dimension scoring + semantic search |
21
+ | Hubs, orphans, paths? | Not possible | Pre-indexed graph analysis |
22
22
 
23
23
  51 tools across 17 categories. 6-line config. Zero cloud dependencies.
24
24
 
@@ -35,8 +35,6 @@ Then ask: *"How much have I billed Acme Corp?"*
35
35
 
36
36
  ## See It Work
37
37
 
38
- ![Flywheel demo](https://raw.githubusercontent.com/velvetmonkey/flywheel-memory/main/demos/flywheel-demo.gif)
39
-
40
38
  ### Read: "How much have I billed Acme Corp?"
41
39
 
42
40
  From the [carter-strategy](https://github.com/velvetmonkey/flywheel-memory/tree/main/demos/carter-strategy/) demo -- a solo consultant with 3 clients, 5 projects, and $27K in invoices.
@@ -44,49 +42,47 @@ From the [carter-strategy](https://github.com/velvetmonkey/flywheel-memory/tree/
44
42
  ```
45
43
  ❯ How much have I billed Acme Corp?
46
44
 
47
- ● flywheel › get_backlinks
48
- path: "clients/Acme Corp.md"
49
- invoices/INV-2025-047.md
50
- invoices/INV-2025-048.md
51
- projects/Acme Data Migration.md
52
- proposals/Acme Analytics Add-on.md
45
+ ● flywheel › search
46
+ query: "Acme Corp"
47
+ clients/Acme Corp.md, invoices/INV-2025-047.md,
48
+ invoices/INV-2025-048.md, projects/Acme Data Migration.md
49
+
50
+ ● read_file("clients/Acme Corp.md")
51
+ ← total_billed: 156000, rate: $300/hr, status: active
53
52
 
54
- flywheel › get_note_metadata
55
- path: "invoices/INV-2025-047.md"
53
+ read_file("invoices/INV-2025-047.md")
56
54
  ← amount: 15000, status: paid, period: November 2025
57
55
 
58
- flywheel › get_note_metadata
59
- path: "invoices/INV-2025-048.md"
56
+ read_file("invoices/INV-2025-048.md")
60
57
  ← amount: 12000, status: pending, period: December 2025
61
58
 
62
59
  ┌─ RESULT ──────────────────────────────────────────────┐
63
- │ Acme Corp Billing: $27,000
60
+ │ Acme Corp: $156K total billed
64
61
  │ │
65
62
  │ Paid: $15,000 — Acme Data Migration (Nov 2025) │
66
63
  │ Pending: $12,000 — Acme Data Migration (Dec 2025) │
67
64
  │ │
68
- 4 tool calls · ~160 tokens · 0 files read
65
+ Also: $35K pending proposal (Analytics Add-on)
69
66
  └───────────────────────────────────────────────────────┘
70
67
  ```
71
68
 
72
- Claude didn't read any files. It navigated the graph: backlinks to find related notes, metadata to extract the numbers.
69
+ Flywheel's indexed search found all Acme-related notes in one call. Claude read the files it needed for billing details. No grepping, no guessing paths.
73
70
 
74
- ```
75
- Same 3 queries without Flywheel: 11,150 tokens (reading files repeatedly)
76
- Same 3 queries with Flywheel: 300 tokens (querying the index)
77
- 37x savings
78
- ```
71
+ Flywheel's search found all related notes in one call. Without it, Claude would grep for "Acme" and scan every matching file.
72
+
73
+ The bigger difference isn't just tokens — it's that Flywheel answers structural questions (backlinks, hubs, shortest paths, schema analysis) that file-level access can't answer at all.
79
74
 
80
75
  ### Write: Auto-wikilinks on every mutation
81
76
 
82
77
  ```
83
- ❯ Log that I finished the Acme strategy deck
78
+ ❯ Log that Stacy Thompson reviewed the API Security Checklist for Acme before the Beta Corp Dashboard kickoff
84
79
 
85
80
  ● flywheel › vault_add_to_section
86
81
  path: "daily-notes/2026-01-04.md"
87
82
  section: "Log"
88
- content: "finished the [[Acme Corp]] strategy deck"
89
- ↑ auto-linked because Acme Corp.md exists
83
+ content: "[[Stacy Thompson]] reviewed the [[API Security Checklist]] for [[Acme Corp|Acme]] before the [[Beta Corp Dashboard]] kickoff → [[GlobalBank API Audit]], [[Acme Analytics Add-on]], [[Acme Data Migration]]"
84
+ 4 entities auto-linked "Acme" resolved to Acme Corp via alias
85
+ → 3 contextual suggestions appended (scored ≥12 via co-occurrence with linked entities)
90
86
  ```
91
87
 
92
88
  Try it yourself: `cd demos/carter-strategy && claude`
@@ -194,8 +190,8 @@ graph LR
194
190
  ```
195
191
 
196
192
  ```
197
- Input: "Met with Sarah about the data migration"
198
- Output: "Met with [[Sarah Mitchell]] about the [[Acme Data Migration]]"
193
+ Input: "Stacy Thompson finished reviewing the API Security Checklist for the Beta Corp Dashboard"
194
+ Output: "[[Stacy Thompson]] finished reviewing the [[API Security Checklist]] for the [[Beta Corp Dashboard]]"
199
195
  ```
200
196
 
201
197
  No manual linking. No broken references. Use compounds into structure, structure compounds into intelligence.
package/dist/index.js CHANGED
@@ -1886,24 +1886,42 @@ async function initEmbeddings() {
1886
1886
  if (pipeline) return;
1887
1887
  if (initPromise) return initPromise;
1888
1888
  initPromise = (async () => {
1889
- try {
1890
- const transformers = await Function("specifier", "return import(specifier)")("@huggingface/transformers");
1891
- pipeline = await transformers.pipeline("feature-extraction", activeModelConfig.id, {
1892
- dtype: "fp32"
1893
- });
1894
- if (activeModelConfig.dims === 0) {
1895
- const probe = await pipeline("test", { pooling: "mean", normalize: true });
1896
- activeModelConfig.dims = probe.data.length;
1897
- console.error(`[Semantic] Probed model ${activeModelConfig.id}: ${activeModelConfig.dims} dims`);
1898
- }
1899
- } catch (err) {
1900
- initPromise = null;
1901
- if (err instanceof Error && (err.message.includes("Cannot find package") || err.message.includes("MODULE_NOT_FOUND") || err.message.includes("Cannot find module") || err.message.includes("ERR_MODULE_NOT_FOUND"))) {
1902
- throw new Error(
1903
- "Semantic search requires @huggingface/transformers. Install it with: npm install @huggingface/transformers"
1904
- );
1889
+ const MAX_RETRIES = 3;
1890
+ const RETRY_DELAYS = [2e3, 5e3, 1e4];
1891
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
1892
+ try {
1893
+ const transformers = await Function("specifier", "return import(specifier)")("@huggingface/transformers");
1894
+ console.error(`[Semantic] Loading model ${activeModelConfig.id} (~23MB, cached after first download)...`);
1895
+ pipeline = await transformers.pipeline("feature-extraction", activeModelConfig.id, {
1896
+ dtype: "fp32"
1897
+ });
1898
+ console.error(`[Semantic] Model loaded successfully`);
1899
+ if (activeModelConfig.dims === 0) {
1900
+ const probe = await pipeline("test", { pooling: "mean", normalize: true });
1901
+ activeModelConfig.dims = probe.data.length;
1902
+ console.error(`[Semantic] Probed model ${activeModelConfig.id}: ${activeModelConfig.dims} dims`);
1903
+ }
1904
+ return;
1905
+ } catch (err) {
1906
+ if (err instanceof Error && (err.message.includes("Cannot find package") || err.message.includes("MODULE_NOT_FOUND") || err.message.includes("Cannot find module") || err.message.includes("ERR_MODULE_NOT_FOUND"))) {
1907
+ initPromise = null;
1908
+ throw new Error(
1909
+ "Semantic search requires @huggingface/transformers. Install it with: npm install @huggingface/transformers"
1910
+ );
1911
+ }
1912
+ if (attempt < MAX_RETRIES) {
1913
+ const delay = RETRY_DELAYS[attempt - 1];
1914
+ console.error(`[Semantic] Model load failed (attempt ${attempt}/${MAX_RETRIES}): ${err instanceof Error ? err.message : err}`);
1915
+ console.error(`[Semantic] Retrying in ${delay / 1e3}s...`);
1916
+ await new Promise((resolve2) => setTimeout(resolve2, delay));
1917
+ pipeline = null;
1918
+ } else {
1919
+ console.error(`[Semantic] Model load failed after ${MAX_RETRIES} attempts: ${err instanceof Error ? err.message : err}`);
1920
+ console.error(`[Semantic] Semantic search disabled. Keyword search (BM25) remains available.`);
1921
+ initPromise = null;
1922
+ throw err;
1923
+ }
1905
1924
  }
1906
- throw err;
1907
1925
  }
1908
1926
  })();
1909
1927
  return initPromise;
@@ -2552,7 +2570,7 @@ function deserializeVaultIndex(data) {
2552
2570
  builtAt: new Date(data.builtAt)
2553
2571
  };
2554
2572
  }
2555
- function loadVaultIndexFromCache(stateDb2, scannedFileCount, maxAgeMs = 24 * 60 * 60 * 1e3, tolerancePercent = 5) {
2573
+ function loadVaultIndexFromCache(stateDb2, scannedFileCount, maxAgeMs = 24 * 60 * 60 * 1e3, tolerancePercent = 5, newestFileMtime) {
2556
2574
  const info = getVaultIndexCacheInfo(stateDb2);
2557
2575
  if (!info) {
2558
2576
  console.error("[Flywheel] No cached index found");
@@ -2569,6 +2587,10 @@ function loadVaultIndexFromCache(stateDb2, scannedFileCount, maxAgeMs = 24 * 60
2569
2587
  console.error(`[Flywheel] Cache invalid: too old (${Math.round(age / 1e3 / 60)} minutes)`);
2570
2588
  return null;
2571
2589
  }
2590
+ if (newestFileMtime && newestFileMtime.getTime() > info.builtAt.getTime()) {
2591
+ console.error(`[Flywheel] Cache invalid: files modified since cache was built (newest file: ${newestFileMtime.toISOString()}, cache built: ${info.builtAt.toISOString()})`);
2592
+ return null;
2593
+ }
2572
2594
  const data = loadVaultIndexCache(stateDb2);
2573
2595
  if (!data) {
2574
2596
  console.error("[Flywheel] Failed to load cached index data");
@@ -9570,7 +9592,8 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
9570
9592
  import { z as z4 } from "zod";
9571
9593
  import {
9572
9594
  searchEntities,
9573
- searchEntitiesPrefix
9595
+ searchEntitiesPrefix,
9596
+ getEntityByName as getEntityByName2
9574
9597
  } from "@velvetmonkey/vault-core";
9575
9598
  function matchesFrontmatter(note, where) {
9576
9599
  for (const [key, value] of Object.entries(where)) {
@@ -9618,6 +9641,37 @@ function inFolder(note, folder) {
9618
9641
  const normalizedFolder = folder.endsWith("/") ? folder : folder + "/";
9619
9642
  return note.path.startsWith(normalizedFolder) || note.path.split("/")[0] === folder.replace("/", "");
9620
9643
  }
9644
+ function enrichResult(result, index, stateDb2) {
9645
+ const note = index.notes.get(result.path);
9646
+ const normalizedPath = result.path.toLowerCase().replace(/\.md$/, "");
9647
+ const backlinks = index.backlinks.get(normalizedPath) || [];
9648
+ const enriched = {
9649
+ path: result.path,
9650
+ title: result.title
9651
+ };
9652
+ if (result.snippet) enriched.snippet = result.snippet;
9653
+ if (note) {
9654
+ enriched.frontmatter = note.frontmatter;
9655
+ enriched.tags = note.tags;
9656
+ enriched.aliases = note.aliases;
9657
+ enriched.backlink_count = backlinks.length;
9658
+ enriched.outlink_count = note.outlinks.length;
9659
+ enriched.modified = note.modified.toISOString();
9660
+ if (note.created) enriched.created = note.created.toISOString();
9661
+ }
9662
+ if (stateDb2) {
9663
+ try {
9664
+ const entity = getEntityByName2(stateDb2, result.title);
9665
+ if (entity) {
9666
+ enriched.category = entity.category;
9667
+ enriched.hub_score = entity.hubScore;
9668
+ if (entity.description) enriched.description = entity.description;
9669
+ }
9670
+ } catch {
9671
+ }
9672
+ }
9673
+ return enriched;
9674
+ }
9621
9675
  function sortNotes(notes, sortBy, order) {
9622
9676
  const sorted = [...notes];
9623
9677
  sorted.sort((a, b) => {
@@ -9825,22 +9879,20 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
9825
9879
  const fts5Map = new Map(fts5Results.map((r) => [normalizePath2(r.path), r]));
9826
9880
  const semanticMap = new Map(semanticResults.map((r) => [normalizePath2(r.path), r]));
9827
9881
  const entityMap = new Map(entityResults.map((r) => [normalizePath2(r.path), r]));
9828
- const merged = Array.from(allPaths).map((p) => ({
9829
- path: p,
9830
- title: fts5Map.get(p)?.title || semanticMap.get(p)?.title || entityMap.get(p)?.name || p.replace(/\.md$/, "").split("/").pop() || p,
9831
- snippet: fts5Map.get(p)?.snippet,
9832
- rrf_score: Math.round((rrfScores.get(p) || 0) * 1e4) / 1e4,
9833
- in_fts5: fts5Map.has(p),
9834
- in_semantic: semanticMap.has(p),
9835
- in_entity: entityMap.has(p)
9836
- }));
9837
- merged.sort((a, b) => b.rrf_score - a.rrf_score);
9882
+ const merged = Array.from(allPaths).map((p) => {
9883
+ const title = fts5Map.get(p)?.title || semanticMap.get(p)?.title || entityMap.get(p)?.name || p.replace(/\.md$/, "").split("/").pop() || p;
9884
+ const snippet = fts5Map.get(p)?.snippet;
9885
+ const rrfScore = rrfScores.get(p) || 0;
9886
+ return { rrfScore, ...enrichResult({ path: p, title, snippet }, index, getStateDb()) };
9887
+ });
9888
+ merged.sort((a, b) => b.rrfScore - a.rrfScore);
9889
+ const results = merged.slice(0, limit).map(({ rrfScore, ...rest }) => rest);
9838
9890
  return { content: [{ type: "text", text: JSON.stringify({
9839
9891
  scope,
9840
9892
  method: "hybrid",
9841
9893
  query,
9842
9894
  total_results: Math.min(merged.length, limit),
9843
- results: merged.slice(0, limit)
9895
+ results
9844
9896
  }, null, 2) }] };
9845
9897
  } catch (err) {
9846
9898
  console.error("[Semantic] Hybrid search failed, falling back to FTS5:", err instanceof Error ? err.message : err);
@@ -9849,11 +9901,10 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
9849
9901
  if (entityResults.length > 0) {
9850
9902
  const normalizePath2 = (p) => p.replace(/\\/g, "/").replace(/\/+/g, "/");
9851
9903
  const fts5Map = new Map(fts5Results.map((r) => [normalizePath2(r.path), r]));
9852
- const entityNormMap = new Map(entityResults.map((r) => [normalizePath2(r.path), r]));
9853
9904
  const entityRanked = entityResults.filter((r) => !fts5Map.has(normalizePath2(r.path)));
9854
9905
  const merged = [
9855
- ...fts5Results.map((r) => ({ path: r.path, title: r.title, snippet: r.snippet, in_entity: entityNormMap.has(normalizePath2(r.path)) })),
9856
- ...entityRanked.map((r) => ({ path: r.path, title: r.name, snippet: void 0, in_entity: true }))
9906
+ ...fts5Results.map((r) => enrichResult({ path: r.path, title: r.title, snippet: r.snippet }, index, getStateDb())),
9907
+ ...entityRanked.map((r) => enrichResult({ path: r.path, title: r.name }, index, getStateDb()))
9857
9908
  ];
9858
9909
  return { content: [{ type: "text", text: JSON.stringify({
9859
9910
  scope: "content",
@@ -9863,12 +9914,13 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb) {
9863
9914
  results: merged.slice(0, limit)
9864
9915
  }, null, 2) }] };
9865
9916
  }
9917
+ const enrichedFts5 = fts5Results.map((r) => enrichResult({ path: r.path, title: r.title, snippet: r.snippet }, index, getStateDb()));
9866
9918
  return { content: [{ type: "text", text: JSON.stringify({
9867
9919
  scope: "content",
9868
9920
  method: "fts5",
9869
9921
  query,
9870
- total_results: fts5Results.length,
9871
- results: fts5Results
9922
+ total_results: enrichedFts5.length,
9923
+ results: enrichedFts5
9872
9924
  }, null, 2) }] };
9873
9925
  }
9874
9926
  return { content: [{ type: "text", text: JSON.stringify({ error: "Invalid scope" }, null, 2) }] };
@@ -10679,7 +10731,8 @@ async function findSections(index, headingPattern, vaultPath2, folder) {
10679
10731
  }
10680
10732
 
10681
10733
  // src/tools/read/primitives.ts
10682
- function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = () => ({})) {
10734
+ import { getEntityByName as getEntityByName3 } from "@velvetmonkey/vault-core";
10735
+ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = () => ({}), getStateDb = () => null) {
10683
10736
  server2.registerTool(
10684
10737
  "get_note_structure",
10685
10738
  {
@@ -10707,8 +10760,31 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = ()
10707
10760
  }
10708
10761
  }
10709
10762
  }
10763
+ const note = index.notes.get(path33);
10764
+ const enriched = { ...result };
10765
+ if (note) {
10766
+ enriched.frontmatter = note.frontmatter;
10767
+ enriched.tags = note.tags;
10768
+ enriched.aliases = note.aliases;
10769
+ const normalizedPath = path33.toLowerCase().replace(/\.md$/, "");
10770
+ const backlinks = index.backlinks.get(normalizedPath) || [];
10771
+ enriched.backlink_count = backlinks.length;
10772
+ enriched.outlink_count = note.outlinks.length;
10773
+ }
10774
+ const stateDb2 = getStateDb();
10775
+ if (stateDb2 && note) {
10776
+ try {
10777
+ const entity = getEntityByName3(stateDb2, note.title);
10778
+ if (entity) {
10779
+ enriched.category = entity.category;
10780
+ enriched.hub_score = entity.hubScore;
10781
+ if (entity.description) enriched.description = entity.description;
10782
+ }
10783
+ } catch {
10784
+ }
10785
+ }
10710
10786
  return {
10711
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
10787
+ content: [{ type: "text", text: JSON.stringify(enriched, null, 2) }]
10712
10788
  };
10713
10789
  }
10714
10790
  );
@@ -19363,7 +19439,7 @@ var ALL_CATEGORIES = [
19363
19439
  "policy",
19364
19440
  "memory"
19365
19441
  ];
19366
- var DEFAULT_PRESET = "full";
19442
+ var DEFAULT_PRESET = "minimal";
19367
19443
  function parseEnabledCategories() {
19368
19444
  const envValue = (process.env.FLYWHEEL_TOOLS ?? process.env.FLYWHEEL_PRESET)?.trim();
19369
19445
  if (!envValue) {
@@ -19565,7 +19641,7 @@ registerSystemTools(
19565
19641
  registerGraphTools(server, () => vaultIndex, () => vaultPath, () => stateDb);
19566
19642
  registerWikilinkTools(server, () => vaultIndex, () => vaultPath);
19567
19643
  registerQueryTools(server, () => vaultIndex, () => vaultPath, () => stateDb);
19568
- registerPrimitiveTools(server, () => vaultIndex, () => vaultPath, () => flywheelConfig);
19644
+ registerPrimitiveTools(server, () => vaultIndex, () => vaultPath, () => flywheelConfig, () => stateDb);
19569
19645
  registerGraphAnalysisTools(server, () => vaultIndex, () => vaultPath, () => stateDb, () => flywheelConfig);
19570
19646
  registerVaultSchemaTools(server, () => vaultIndex, () => vaultPath);
19571
19647
  registerNoteIntelligenceTools(server, () => vaultIndex, () => vaultPath, () => flywheelConfig);
@@ -19669,7 +19745,8 @@ async function main() {
19669
19745
  const files = await scanVault(vaultPath);
19670
19746
  const noteCount = files.length;
19671
19747
  serverLog("index", `Found ${noteCount} markdown files`);
19672
- cachedIndex = loadVaultIndexFromCache(stateDb, noteCount);
19748
+ const newestMtime = files.reduce((max, f) => f.modified > max ? f.modified : max, /* @__PURE__ */ new Date(0));
19749
+ cachedIndex = loadVaultIndexFromCache(stateDb, noteCount, void 0, void 0, newestMtime);
19673
19750
  } catch (err) {
19674
19751
  serverLog("index", `Cache check failed: ${err instanceof Error ? err.message : err}`, "warn");
19675
19752
  }
@@ -19870,30 +19947,45 @@ async function runPostIndexWork(index) {
19870
19947
  if (hasEmbeddingsIndex() && !modelChanged) {
19871
19948
  serverLog("semantic", "Embeddings already built, skipping full scan");
19872
19949
  } else {
19873
- setEmbeddingsBuilding(true);
19874
- buildEmbeddingsIndex(vaultPath, (p) => {
19875
- if (p.current % 100 === 0 || p.current === p.total) {
19876
- serverLog("semantic", `Embedding ${p.current}/${p.total} notes...`);
19877
- }
19878
- }).then(async () => {
19879
- if (stateDb) {
19880
- const entities = getAllEntitiesFromDb3(stateDb);
19881
- if (entities.length > 0) {
19882
- const entityMap = new Map(entities.map((e) => [e.name, {
19883
- name: e.name,
19884
- path: e.path,
19885
- category: e.category,
19886
- aliases: e.aliases
19887
- }]));
19888
- await buildEntityEmbeddingsIndex(vaultPath, entityMap);
19950
+ const MAX_BUILD_RETRIES = 2;
19951
+ const attemptBuild = async (attempt) => {
19952
+ setEmbeddingsBuilding(true);
19953
+ try {
19954
+ await buildEmbeddingsIndex(vaultPath, (p) => {
19955
+ if (p.current % 100 === 0 || p.current === p.total) {
19956
+ serverLog("semantic", `Embedding ${p.current}/${p.total} notes...`);
19957
+ }
19958
+ });
19959
+ if (stateDb) {
19960
+ const entities = getAllEntitiesFromDb3(stateDb);
19961
+ if (entities.length > 0) {
19962
+ const entityMap = new Map(entities.map((e) => [e.name, {
19963
+ name: e.name,
19964
+ path: e.path,
19965
+ category: e.category,
19966
+ aliases: e.aliases
19967
+ }]));
19968
+ await buildEntityEmbeddingsIndex(vaultPath, entityMap);
19969
+ }
19889
19970
  }
19971
+ loadEntityEmbeddingsToMemory();
19972
+ setEmbeddingsBuildState("complete");
19973
+ serverLog("semantic", "Embeddings ready \u2014 searches now use hybrid ranking");
19974
+ } catch (err) {
19975
+ const msg = err instanceof Error ? err.message : String(err);
19976
+ if (attempt < MAX_BUILD_RETRIES) {
19977
+ const delay = 1e4;
19978
+ serverLog("semantic", `Build failed (attempt ${attempt}/${MAX_BUILD_RETRIES}): ${msg}. Retrying in ${delay / 1e3}s...`, "error");
19979
+ await new Promise((resolve2) => setTimeout(resolve2, delay));
19980
+ return attemptBuild(attempt + 1);
19981
+ }
19982
+ serverLog("semantic", `Embeddings build failed after ${MAX_BUILD_RETRIES} attempts: ${msg}`, "error");
19983
+ serverLog("semantic", "Keyword search (BM25) remains fully available", "error");
19984
+ } finally {
19985
+ setEmbeddingsBuilding(false);
19890
19986
  }
19891
- loadEntityEmbeddingsToMemory();
19892
- setEmbeddingsBuildState("complete");
19893
- serverLog("semantic", "Embeddings ready");
19894
- }).catch((err) => {
19895
- serverLog("semantic", `Embeddings build failed: ${err instanceof Error ? err.message : err}`, "error");
19896
- });
19987
+ };
19988
+ attemptBuild(1);
19897
19989
  }
19898
19990
  } else {
19899
19991
  serverLog("semantic", "Skipping \u2014 FLYWHEEL_SKIP_EMBEDDINGS");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.0.76",
3
+ "version": "2.0.78",
4
4
  "description": "MCP server that gives Claude full read/write access to your Obsidian vault. Select from 51 tools for search, backlinks, graph queries, mutations, agent memory, and hybrid semantic search.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@modelcontextprotocol/sdk": "^1.25.1",
55
- "@velvetmonkey/vault-core": "^2.0.75",
55
+ "@velvetmonkey/vault-core": "^2.0.78",
56
56
  "better-sqlite3": "^11.0.0",
57
57
  "chokidar": "^4.0.0",
58
58
  "gray-matter": "^4.0.3",