@velvetmonkey/flywheel-crank 0.8.1 → 0.9.2

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 +133 -45
  2. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -14,6 +14,10 @@ import matter from "gray-matter";
14
14
 
15
15
  // src/core/constants.ts
16
16
  var HEADING_REGEX = /^(#{1,6})\s+(.+)$/;
17
+ function estimateTokens(content) {
18
+ const str = typeof content === "string" ? content : JSON.stringify(content);
19
+ return Math.ceil(str.length / 4);
20
+ }
17
21
 
18
22
  // src/core/writer.ts
19
23
  var SENSITIVE_PATH_PATTERNS = [
@@ -158,8 +162,8 @@ function formatContent(content, format) {
158
162
  return trimmed;
159
163
  }
160
164
  }
161
- function detectListIndentation(lines, insertLineIndex, sectionStartLine) {
162
- for (let i = insertLineIndex - 1; i >= sectionStartLine; i--) {
165
+ function detectSectionBaseIndentation(lines, sectionStartLine, sectionEndLine) {
166
+ for (let i = sectionStartLine; i <= sectionEndLine; i++) {
163
167
  const line = lines[i];
164
168
  const trimmed = line.trim();
165
169
  if (trimmed === "")
@@ -169,7 +173,7 @@ function detectListIndentation(lines, insertLineIndex, sectionStartLine) {
169
173
  const indent = listMatch[1] || listMatch[2] || listMatch[3] || "";
170
174
  return indent;
171
175
  }
172
- if (trimmed.match(/^#+\s/)) {
176
+ if (i > sectionStartLine && trimmed.match(/^#+\s/)) {
173
177
  break;
174
178
  }
175
179
  }
@@ -212,7 +216,7 @@ function insertInSection(content, section, newContent, position, options) {
212
216
  }
213
217
  if (lastContentLineIdx >= section.contentStartLine && isEmptyPlaceholder(lines[lastContentLineIdx])) {
214
218
  if (options?.preserveListNesting) {
215
- const indent = detectListIndentation(lines, lastContentLineIdx, section.contentStartLine);
219
+ const indent = detectSectionBaseIndentation(lines, section.contentStartLine, section.endLine);
216
220
  const indentedContent = formattedContent.split("\n").map((line) => indent + line).join("\n");
217
221
  lines[lastContentLineIdx] = indentedContent;
218
222
  } else {
@@ -231,7 +235,7 @@ function insertInSection(content, section, newContent, position, options) {
231
235
  insertLine = section.contentStartLine;
232
236
  }
233
237
  if (options?.preserveListNesting) {
234
- const indent = detectListIndentation(lines, insertLine, section.contentStartLine);
238
+ const indent = detectSectionBaseIndentation(lines, section.contentStartLine, section.endLine);
235
239
  const indentedContent = formattedContent.split("\n").map((line) => indent + line).join("\n");
236
240
  lines.splice(insertLine, 0, indentedContent);
237
241
  } else {
@@ -510,9 +514,12 @@ async function undoLastCommit(vaultPath2) {
510
514
  import {
511
515
  scanVaultEntities,
512
516
  getAllEntities,
517
+ getEntityName,
518
+ getEntityAliases,
513
519
  applyWikilinks,
514
520
  loadEntityCache,
515
- saveEntityCache
521
+ saveEntityCache,
522
+ ENTITY_CACHE_VERSION
516
523
  } from "@velvetmonkey/vault-core";
517
524
  import path4 from "path";
518
525
 
@@ -1411,6 +1418,12 @@ async function initializeEntityIndex(vaultPath2) {
1411
1418
  try {
1412
1419
  const cached = await loadEntityCache(cacheFile);
1413
1420
  if (cached) {
1421
+ const cacheVersion = cached._metadata.version ?? 1;
1422
+ if (cacheVersion < ENTITY_CACHE_VERSION) {
1423
+ console.error(`[Crank] Cache version ${cacheVersion} < ${ENTITY_CACHE_VERSION}, rebuilding...`);
1424
+ await rebuildIndex(vaultPath2, cacheFile);
1425
+ return;
1426
+ }
1414
1427
  entityIndex = cached;
1415
1428
  indexReady = true;
1416
1429
  console.error(`[Crank] Loaded ${cached._metadata.total_entities} entities from cache`);
@@ -1569,33 +1582,59 @@ var STRICTNESS_CONFIGS = {
1569
1582
  var DEFAULT_STRICTNESS = "conservative";
1570
1583
  var MIN_SUGGESTION_SCORE = STRICTNESS_CONFIGS.balanced.minSuggestionScore;
1571
1584
  var MIN_MATCH_RATIO = STRICTNESS_CONFIGS.balanced.minMatchRatio;
1572
- function scoreEntity(entityName, contentTokens, contentStems, config) {
1573
- const entityTokens = tokenize(entityName);
1574
- if (entityTokens.length === 0)
1575
- return 0;
1576
- const entityStems = entityTokens.map((t) => stem(t));
1585
+ var FULL_ALIAS_MATCH_BONUS = 8;
1586
+ function scoreNameAgainstContent(name, contentTokens, contentStems, config) {
1587
+ const nameTokens = tokenize(name);
1588
+ if (nameTokens.length === 0) {
1589
+ return { score: 0, matchedWords: 0, exactMatches: 0, totalTokens: 0 };
1590
+ }
1591
+ const nameStems = nameTokens.map((t) => stem(t));
1577
1592
  let score = 0;
1578
1593
  let matchedWords = 0;
1579
1594
  let exactMatches = 0;
1580
- for (let i = 0; i < entityTokens.length; i++) {
1581
- const token = entityTokens[i];
1582
- const entityStem = entityStems[i];
1595
+ for (let i = 0; i < nameTokens.length; i++) {
1596
+ const token = nameTokens[i];
1597
+ const nameStem = nameStems[i];
1583
1598
  if (contentTokens.has(token)) {
1584
1599
  score += config.exactMatchBonus;
1585
1600
  matchedWords++;
1586
1601
  exactMatches++;
1587
- } else if (contentStems.has(entityStem)) {
1602
+ } else if (contentStems.has(nameStem)) {
1588
1603
  score += config.stemMatchBonus;
1589
1604
  matchedWords++;
1590
1605
  }
1591
1606
  }
1592
- if (entityTokens.length > 1) {
1593
- const matchRatio = matchedWords / entityTokens.length;
1607
+ return { score, matchedWords, exactMatches, totalTokens: nameTokens.length };
1608
+ }
1609
+ function scoreEntity(entity, contentTokens, contentStems, config) {
1610
+ const entityName = getEntityName(entity);
1611
+ const aliases = getEntityAliases(entity);
1612
+ const nameResult = scoreNameAgainstContent(entityName, contentTokens, contentStems, config);
1613
+ let bestAliasResult = { score: 0, matchedWords: 0, exactMatches: 0, totalTokens: 0 };
1614
+ for (const alias of aliases) {
1615
+ const aliasResult = scoreNameAgainstContent(alias, contentTokens, contentStems, config);
1616
+ if (aliasResult.score > bestAliasResult.score) {
1617
+ bestAliasResult = aliasResult;
1618
+ }
1619
+ }
1620
+ const bestResult = nameResult.score >= bestAliasResult.score ? nameResult : bestAliasResult;
1621
+ let { score, matchedWords, exactMatches, totalTokens } = bestResult;
1622
+ if (totalTokens === 0)
1623
+ return 0;
1624
+ for (const alias of aliases) {
1625
+ const aliasLower = alias.toLowerCase();
1626
+ if (aliasLower.length >= 4 && !/\s/.test(aliasLower) && contentTokens.has(aliasLower)) {
1627
+ score += FULL_ALIAS_MATCH_BONUS;
1628
+ break;
1629
+ }
1630
+ }
1631
+ if (totalTokens > 1) {
1632
+ const matchRatio = matchedWords / totalTokens;
1594
1633
  if (matchRatio < config.minMatchRatio) {
1595
1634
  return 0;
1596
1635
  }
1597
1636
  }
1598
- if (config.requireMultipleMatches && entityTokens.length === 1) {
1637
+ if (config.requireMultipleMatches && totalTokens === 1) {
1599
1638
  if (exactMatches === 0) {
1600
1639
  return 0;
1601
1640
  }
@@ -1628,7 +1667,7 @@ function suggestRelatedLinks(content, options = {}) {
1628
1667
  const scoredEntities = [];
1629
1668
  const directlyMatchedEntities = /* @__PURE__ */ new Set();
1630
1669
  for (const entity of entities) {
1631
- const entityName = typeof entity === "string" ? entity : entity.name;
1670
+ const entityName = getEntityName(entity);
1632
1671
  if (!entityName)
1633
1672
  continue;
1634
1673
  if (entityName.length > MAX_ENTITY_LENGTH) {
@@ -1640,7 +1679,7 @@ function suggestRelatedLinks(content, options = {}) {
1640
1679
  if (linkedEntities.has(entityName.toLowerCase())) {
1641
1680
  continue;
1642
1681
  }
1643
- const score = scoreEntity(entityName, contentTokens, contentStems, config);
1682
+ const score = scoreEntity(entity, contentTokens, contentStems, config);
1644
1683
  if (score > 0) {
1645
1684
  directlyMatchedEntities.add(entityName);
1646
1685
  }
@@ -1650,7 +1689,7 @@ function suggestRelatedLinks(content, options = {}) {
1650
1689
  }
1651
1690
  if (cooccurrenceIndex && directlyMatchedEntities.size > 0) {
1652
1691
  for (const entity of entities) {
1653
- const entityName = typeof entity === "string" ? entity : entity.name;
1692
+ const entityName = getEntityName(entity);
1654
1693
  if (!entityName)
1655
1694
  continue;
1656
1695
  if (entityName.length > MAX_ENTITY_LENGTH)
@@ -1710,8 +1749,10 @@ function registerMutationTools(server2, vaultPath2) {
1710
1749
  const result2 = {
1711
1750
  success: false,
1712
1751
  message: `File not found: ${notePath}`,
1713
- path: notePath
1752
+ path: notePath,
1753
+ tokensEstimate: 0
1714
1754
  };
1755
+ result2.tokensEstimate = estimateTokens(result2);
1715
1756
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1716
1757
  }
1717
1758
  const { content: fileContent, frontmatter } = await readVaultFile(vaultPath2, notePath);
@@ -1720,8 +1761,10 @@ function registerMutationTools(server2, vaultPath2) {
1720
1761
  const result2 = {
1721
1762
  success: false,
1722
1763
  message: `Section not found: ${section}`,
1723
- path: notePath
1764
+ path: notePath,
1765
+ tokensEstimate: 0
1724
1766
  };
1767
+ result2.tokensEstimate = estimateTokens(result2);
1725
1768
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1726
1769
  }
1727
1770
  let { content: processedContent, wikilinkInfo } = maybeApplyWikilinks(content, skipWikilinks);
@@ -1761,15 +1804,20 @@ function registerMutationTools(server2, vaultPath2) {
1761
1804
  path: notePath,
1762
1805
  preview,
1763
1806
  gitCommit,
1764
- gitError
1807
+ gitError,
1808
+ tokensEstimate: 0
1809
+ // Will be set below
1765
1810
  };
1811
+ result.tokensEstimate = estimateTokens(result);
1766
1812
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1767
1813
  } catch (error) {
1768
1814
  const result = {
1769
1815
  success: false,
1770
1816
  message: `Failed to add content: ${error instanceof Error ? error.message : String(error)}`,
1771
- path: notePath
1817
+ path: notePath,
1818
+ tokensEstimate: 0
1772
1819
  };
1820
+ result.tokensEstimate = estimateTokens(result);
1773
1821
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1774
1822
  }
1775
1823
  }
@@ -1794,8 +1842,10 @@ function registerMutationTools(server2, vaultPath2) {
1794
1842
  const result2 = {
1795
1843
  success: false,
1796
1844
  message: `File not found: ${notePath}`,
1797
- path: notePath
1845
+ path: notePath,
1846
+ tokensEstimate: 0
1798
1847
  };
1848
+ result2.tokensEstimate = estimateTokens(result2);
1799
1849
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1800
1850
  }
1801
1851
  const { content: fileContent, frontmatter } = await readVaultFile(vaultPath2, notePath);
@@ -1804,8 +1854,10 @@ function registerMutationTools(server2, vaultPath2) {
1804
1854
  const result2 = {
1805
1855
  success: false,
1806
1856
  message: `Section not found: ${section}`,
1807
- path: notePath
1857
+ path: notePath,
1858
+ tokensEstimate: 0
1808
1859
  };
1860
+ result2.tokensEstimate = estimateTokens(result2);
1809
1861
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1810
1862
  }
1811
1863
  const removeResult = removeFromSection(
@@ -1819,8 +1871,10 @@ function registerMutationTools(server2, vaultPath2) {
1819
1871
  const result2 = {
1820
1872
  success: false,
1821
1873
  message: `No content matching "${pattern}" found in section "${sectionBoundary.name}"`,
1822
- path: notePath
1874
+ path: notePath,
1875
+ tokensEstimate: 0
1823
1876
  };
1877
+ result2.tokensEstimate = estimateTokens(result2);
1824
1878
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1825
1879
  }
1826
1880
  await writeVaultFile(vaultPath2, notePath, removeResult.content, frontmatter);
@@ -1840,15 +1894,19 @@ function registerMutationTools(server2, vaultPath2) {
1840
1894
  path: notePath,
1841
1895
  preview: removeResult.removedLines.join("\n"),
1842
1896
  gitCommit,
1843
- gitError
1897
+ gitError,
1898
+ tokensEstimate: 0
1844
1899
  };
1900
+ result.tokensEstimate = estimateTokens(result);
1845
1901
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1846
1902
  } catch (error) {
1847
1903
  const result = {
1848
1904
  success: false,
1849
1905
  message: `Failed to remove content: ${error instanceof Error ? error.message : String(error)}`,
1850
- path: notePath
1906
+ path: notePath,
1907
+ tokensEstimate: 0
1851
1908
  };
1909
+ result.tokensEstimate = estimateTokens(result);
1852
1910
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1853
1911
  }
1854
1912
  }
@@ -1877,8 +1935,10 @@ function registerMutationTools(server2, vaultPath2) {
1877
1935
  const result2 = {
1878
1936
  success: false,
1879
1937
  message: `File not found: ${notePath}`,
1880
- path: notePath
1938
+ path: notePath,
1939
+ tokensEstimate: 0
1881
1940
  };
1941
+ result2.tokensEstimate = estimateTokens(result2);
1882
1942
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1883
1943
  }
1884
1944
  const { content: fileContent, frontmatter } = await readVaultFile(vaultPath2, notePath);
@@ -1887,8 +1947,10 @@ function registerMutationTools(server2, vaultPath2) {
1887
1947
  const result2 = {
1888
1948
  success: false,
1889
1949
  message: `Section not found: ${section}`,
1890
- path: notePath
1950
+ path: notePath,
1951
+ tokensEstimate: 0
1891
1952
  };
1953
+ result2.tokensEstimate = estimateTokens(result2);
1892
1954
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1893
1955
  }
1894
1956
  let { content: processedReplacement, wikilinkInfo } = maybeApplyWikilinks(replacement, skipWikilinks);
@@ -1912,8 +1974,10 @@ function registerMutationTools(server2, vaultPath2) {
1912
1974
  const result2 = {
1913
1975
  success: false,
1914
1976
  message: `No content matching "${search}" found in section "${sectionBoundary.name}"`,
1915
- path: notePath
1977
+ path: notePath,
1978
+ tokensEstimate: 0
1916
1979
  };
1980
+ result2.tokensEstimate = estimateTokens(result2);
1917
1981
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1918
1982
  }
1919
1983
  await writeVaultFile(vaultPath2, notePath, replaceResult.content, frontmatter);
@@ -1937,15 +2001,19 @@ function registerMutationTools(server2, vaultPath2) {
1937
2001
  path: notePath,
1938
2002
  preview: previewLines.join("\n"),
1939
2003
  gitCommit,
1940
- gitError
2004
+ gitError,
2005
+ tokensEstimate: 0
1941
2006
  };
2007
+ result.tokensEstimate = estimateTokens(result);
1942
2008
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1943
2009
  } catch (error) {
1944
2010
  const result = {
1945
2011
  success: false,
1946
2012
  message: `Failed to replace content: ${error instanceof Error ? error.message : String(error)}`,
1947
- path: notePath
2013
+ path: notePath,
2014
+ tokensEstimate: 0
1948
2015
  };
2016
+ result.tokensEstimate = estimateTokens(result);
1949
2017
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1950
2018
  }
1951
2019
  }
@@ -2019,8 +2087,10 @@ function registerTaskTools(server2, vaultPath2) {
2019
2087
  const result2 = {
2020
2088
  success: false,
2021
2089
  message: `File not found: ${notePath}`,
2022
- path: notePath
2090
+ path: notePath,
2091
+ tokensEstimate: 0
2023
2092
  };
2093
+ result2.tokensEstimate = estimateTokens(result2);
2024
2094
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2025
2095
  }
2026
2096
  const { content: fileContent, frontmatter } = await readVaultFile(vaultPath2, notePath);
@@ -2031,8 +2101,10 @@ function registerTaskTools(server2, vaultPath2) {
2031
2101
  const result2 = {
2032
2102
  success: false,
2033
2103
  message: `Section not found: ${section}`,
2034
- path: notePath
2104
+ path: notePath,
2105
+ tokensEstimate: 0
2035
2106
  };
2107
+ result2.tokensEstimate = estimateTokens(result2);
2036
2108
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2037
2109
  }
2038
2110
  sectionBoundary = found;
@@ -2046,8 +2118,10 @@ function registerTaskTools(server2, vaultPath2) {
2046
2118
  const result2 = {
2047
2119
  success: false,
2048
2120
  message: `No task found matching "${task}"${section ? ` in section "${section}"` : ""}`,
2049
- path: notePath
2121
+ path: notePath,
2122
+ tokensEstimate: 0
2050
2123
  };
2124
+ result2.tokensEstimate = estimateTokens(result2);
2051
2125
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2052
2126
  }
2053
2127
  const toggleResult = toggleTask(fileContent, matchingTask.line);
@@ -2055,8 +2129,10 @@ function registerTaskTools(server2, vaultPath2) {
2055
2129
  const result2 = {
2056
2130
  success: false,
2057
2131
  message: "Failed to toggle task",
2058
- path: notePath
2132
+ path: notePath,
2133
+ tokensEstimate: 0
2059
2134
  };
2135
+ result2.tokensEstimate = estimateTokens(result2);
2060
2136
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2061
2137
  }
2062
2138
  await writeVaultFile(vaultPath2, notePath, toggleResult.content, frontmatter);
@@ -2078,15 +2154,19 @@ function registerTaskTools(server2, vaultPath2) {
2078
2154
  path: notePath,
2079
2155
  preview: `${checkbox} ${matchingTask.text}`,
2080
2156
  gitCommit,
2081
- gitError
2157
+ gitError,
2158
+ tokensEstimate: 0
2082
2159
  };
2160
+ result.tokensEstimate = estimateTokens(result);
2083
2161
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
2084
2162
  } catch (error) {
2085
2163
  const result = {
2086
2164
  success: false,
2087
2165
  message: `Failed to toggle task: ${error instanceof Error ? error.message : String(error)}`,
2088
- path: notePath
2166
+ path: notePath,
2167
+ tokensEstimate: 0
2089
2168
  };
2169
+ result.tokensEstimate = estimateTokens(result);
2090
2170
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
2091
2171
  }
2092
2172
  }
@@ -2115,8 +2195,10 @@ function registerTaskTools(server2, vaultPath2) {
2115
2195
  const result2 = {
2116
2196
  success: false,
2117
2197
  message: `File not found: ${notePath}`,
2118
- path: notePath
2198
+ path: notePath,
2199
+ tokensEstimate: 0
2119
2200
  };
2201
+ result2.tokensEstimate = estimateTokens(result2);
2120
2202
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2121
2203
  }
2122
2204
  const { content: fileContent, frontmatter } = await readVaultFile(vaultPath2, notePath);
@@ -2125,8 +2207,10 @@ function registerTaskTools(server2, vaultPath2) {
2125
2207
  const result2 = {
2126
2208
  success: false,
2127
2209
  message: `Section not found: ${section}`,
2128
- path: notePath
2210
+ path: notePath,
2211
+ tokensEstimate: 0
2129
2212
  };
2213
+ result2.tokensEstimate = estimateTokens(result2);
2130
2214
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2131
2215
  }
2132
2216
  let { content: processedTask, wikilinkInfo } = maybeApplyWikilinks(task.trim(), skipWikilinks);
@@ -2166,15 +2250,19 @@ function registerTaskTools(server2, vaultPath2) {
2166
2250
  preview: taskLine + (infoLines.length > 0 ? `
2167
2251
  (${infoLines.join("; ")})` : ""),
2168
2252
  gitCommit,
2169
- gitError
2253
+ gitError,
2254
+ tokensEstimate: 0
2170
2255
  };
2256
+ result.tokensEstimate = estimateTokens(result);
2171
2257
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
2172
2258
  } catch (error) {
2173
2259
  const result = {
2174
2260
  success: false,
2175
2261
  message: `Failed to add task: ${error instanceof Error ? error.message : String(error)}`,
2176
- path: notePath
2262
+ path: notePath,
2263
+ tokensEstimate: 0
2177
2264
  };
2265
+ result.tokensEstimate = estimateTokens(result);
2178
2266
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
2179
2267
  }
2180
2268
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-crank",
3
- "version": "0.8.1",
3
+ "version": "0.9.2",
4
4
  "description": "Deterministic vault mutations for Obsidian via MCP",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -41,10 +41,10 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@modelcontextprotocol/sdk": "^1.25.1",
44
- "@velvetmonkey/vault-core": "^0.1.0",
44
+ "@velvetmonkey/vault-core": "^0.2.0",
45
45
  "gray-matter": "^4.0.3",
46
- "zod": "^3.22.4",
47
- "simple-git": "^3.22.0"
46
+ "simple-git": "^3.22.0",
47
+ "zod": "^3.22.4"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@types/node": "^20.10.0",