obsidian_fetch 1.1.2 → 1.3.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6148753dd141c067fc88a83068d2751944bf8b70c096532228d806fc3c9ba1ca
4
- data.tar.gz: 850207c673687d7fe21de00ad2a7761a43d7bbfe92463baa4a987d3c20ea272f
3
+ metadata.gz: 6b142d17c24902e0a12e9cec6e76cd0b013847ec51f32e25ce902cd882eab987
4
+ data.tar.gz: 4c3783977dd9554b9bcba102a73b77a5a59d6e5915004cd3a304011e91a08588
5
5
  SHA512:
6
- metadata.gz: 31e95b02fc5d2937e22b622e4629833512018e5ca66de2a168b7eb0c212a17b35e9863394105305b238d70bf4bcd2ca7574f382353fcc1b1289af7c778faa5b6
7
- data.tar.gz: d2e6cb6b19c502f80e00784b45fe47d1daf1933048acaa7135f94fa2a4b09c919580640f771c50c1ad9ee4ca24d7c7374b05c932e88d7f00637f8aaae5ed7a43
6
+ metadata.gz: c550ba9a5d0c37f0a35d46f4667a1c7d4b79fedf4e10120e7f64877d9b34e5312ae1d913e74056317b5274ad90bf95bdeeec855fb3d146003b8fe656d40679ee
7
+ data.tar.gz: 17cd3805955d03eec7030e52ed07cb4f32c41f65cae11f1fc34a9ea07ffb0b0b4792fc4564b941b9347824a24d327a8f4ff56c3197eaf061101034e30d41820b
data/AGENTS.md ADDED
@@ -0,0 +1,5 @@
1
+ # このリポジトリにおけるエージェントの行動指針
2
+
3
+ ## テストの実行
4
+
5
+ テストは `bundle exec rake test` で実行する。
data/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.3.0] - 2026-10-28
4
+
5
+ - Dependencies: mcp 1.8.2 (constraint `~> 1.8`), puma 8.0.2, rack 3.2.7, simplecov 1.3.2. Required Ruby is 3.3.0 or later.
6
+ - list tool: stopped shuffling. Results are ordered by note name, compared case-insensitively with an ASCII tiebreak. Backlinks, link fallback lists and file enumerations are ordered the same way. When the result is truncated by limit, the truncated priority group shows its head and tail with a line reporting the omitted count.
7
+ - list tool: added the `limit` argument (1 to 200, default 20) and the total number of matches in the response.
8
+ - list tool: folder names are searched, matches are ranked by the distance from the file, and the response reports the breakdown of matches.
9
+ - read tool: a case-insensitive fuzzy match is accepted when no exact match exists, and case variants are suggested when an exact match exists.
10
+ - The initialize response advertises only the tools capability.
11
+
12
+ ## [1.2.0] - 2026-07-10
13
+
14
+ - Vault files are reloaded when they change.
15
+ - isError handling was added to the tools.
16
+ - Tests were restructured and coverage measurement was added.
17
+
18
+ ## [1.1.2] - 2026-04-28
19
+
20
+ - The Puma bind address was changed from 127.0.0.1 to 0.0.0.0 to allow external access.
21
+ - The `enable_json_response` option added in 1.1.1 was reverted.
22
+
23
+ ## [1.1.1] - 2026-04-28
24
+
25
+ - Added the `enable_json_response` option for mcp 0.14.0.
26
+
27
+ ## [1.1.0] - 2026-04-28
28
+
29
+ - Added the Streamable HTTP transport.
30
+ - README was made multilingual.
31
+ - The mcp gem was upgraded to 0.14.0.
32
+
33
+ ## [1.0.0] - 2025-08-20
34
+
35
+ - Switched to the official MCP Ruby SDK.
36
+ - Fixed the way tools are specified.
37
+ - read tool normalizes a path given as `name`.
38
+ - Display shows note names instead of paths.
39
+ - Refactored and extended the list tool.
40
+
3
41
  ## [0.1.0] - 2025-04-27
4
42
 
5
43
  - Initial release
data/README.md CHANGED
@@ -48,7 +48,7 @@ obsidian_fetch /path/to/your/vault --transport streamable-http --port 3000
48
48
  ## Tools
49
49
 
50
50
  - **read**: Read a note from the Obsidian vault. If multiple notes with the same name are found, all will be shown.
51
- - **list**: Search for files with matching names partially.
51
+ - **list**: Search for notes whose name or containing folder names partially match. It returns 20 notes by default. The `limit` argument accepts an integer between 1 and 200, and the response states how many notes matched in total.
52
52
 
53
53
  ## Contributing
54
54
 
data/README_ja.md CHANGED
@@ -52,7 +52,7 @@ obsidian_fetch /path/to/your/vault --transport streamable-http --port 3000
52
52
  ## ツール
53
53
 
54
54
  - **read**: Obsidian Vaultからノートを取得します。同名のノートが複数存在する場合、すべて取得します。
55
- - **list**: ファイル名で検索します。部分一致での検索が可能です。
55
+ - **list**: ノート名とフォルダ名で検索します。部分一致での検索が可能です。既定では20件を返します。limit 引数には1から200の整数を受け付けます。応答にはマッチした総件数が含まれます。
56
56
 
57
57
  ## 貢献
58
58
 
data/Rakefile CHANGED
@@ -1,4 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bundler/gem_tasks"
4
- task default: %i[]
4
+ require 'rake/testtask'
5
+
6
+ task default: [:test]
7
+
8
+ task :test do
9
+ # テスト実行前にカバレッジデータをリセット
10
+ Dir.glob('coverage/.resultset.json*').each { |f| File.delete(f) if File.exist?(f) }
11
+ end
12
+
13
+ Rake::TestTask.new('test') do |t|
14
+ t.test_files = FileList['test/test_*.rb']
15
+ end
data/lib/exe.rb CHANGED
@@ -78,14 +78,13 @@ vault_paths.each do |path|
78
78
  end
79
79
  end
80
80
 
81
- # Obsidian Vaultの初期化
82
- $vault = ObsidianFetch::Vault.new(vault_paths)
83
- STDERR.puts "Found #{$vault.notes.size} notes"
84
- STDERR.puts "Found #{$vault.links_by_file_name.size} links and #{$vault.links_by_file_path.size} files linked by notes"
81
+ $manager = ObsidianFetch::VaultManager.new(vault_paths)
82
+ STDERR.puts "Found #{$manager.vault.notes.size} notes"
83
+ STDERR.puts "Found #{$manager.vault.links_by_file_name.size} links and #{$manager.vault.links_by_file_path.size} files linked by notes"
85
84
  STDERR.puts "Transport type: #{transport_type}"
86
85
  STDERR.puts "Port: #{port}" if transport_type == 'streamable-http'
87
86
 
88
- # readツールの定義
87
+ # MCPツール定義
89
88
  class ReadTool < MCP::Tool
90
89
  description "Read a note from Obsidian vault. If multiple notes with the same name are found, all will be shown."
91
90
  input_schema(
@@ -94,42 +93,42 @@ class ReadTool < MCP::Tool
94
93
  },
95
94
  required: ["name"]
96
95
  )
97
-
98
96
  def self.call(name:)
99
- # 名前が文字列でない場合
100
- return MCP::Tool::Response.new([{ type: "text", text: "Name must be a string" }]) unless name.is_a?(String)
101
-
102
- # Vaultからノートを読み取る
103
- result = $vault.tool_read(name)
104
- MCP::Tool::Response.new([{ type: "text", text: result }])
97
+ return MCP::Tool::Response.new([{ type: "text", text: "Name must be a string" }], error: true) unless name.is_a?(String)
98
+ result = ObsidianFetch.read_note($manager.vault, name)
99
+ MCP::Tool::Response.new([{ type: "text", text: result.text }], error: result.error)
105
100
  end
106
101
  end
107
102
 
108
- # listツールの定義
109
103
  class ListTool < MCP::Tool
110
- description "Search for files with matching names partially."
104
+ description "Search for notes whose name or containing folder names partially match."
111
105
  input_schema(
112
106
  properties: {
113
- name: { type: "string", description: "Note name to search" }
107
+ name: { type: "string", description: "Note name to search" },
108
+ limit: { type: "integer", description: "Number of notes to return. Between 1 and #{ObsidianFetch::MAX_LIST_LIMIT}. Default: #{ObsidianFetch::MAX_LIST_SIZE}." }
114
109
  },
115
110
  required: ["name"]
116
111
  )
117
112
 
118
- def self.call(name:)
119
- # 名前が文字列でない場合
120
- return MCP::Tool::Response.new([{ type: "text", text: "Name must be a string" }]) unless name.is_a?(String)
121
-
122
- # Vaultからノートを検索
123
- result = $vault.tool_list(name)
124
- MCP::Tool::Response.new([{ type: "text", text: result }])
113
+ def self.call(name:, limit: ObsidianFetch::MAX_LIST_SIZE)
114
+ return MCP::Tool::Response.new([{ type: "text", text: "Name must be a string" }], error: true) unless name.is_a?(String)
115
+ result = ObsidianFetch.list_notes($manager.vault, name, limit: limit)
116
+ MCP::Tool::Response.new([{ type: "text", text: result.text }], error: result.error)
125
117
  end
126
118
  end
127
119
 
128
120
  # MCPサーバーの初期化
121
+ #
122
+ # capabilities を明示的に指定しています。
123
+ # MCP::Server は capabilities を渡さない場合、default_capabilities を使います。
124
+ # default_capabilities には tools だけでなく prompts、resources、logging が含まれるため、
125
+ # リソースを持たないこのサーバーでもクライアントが resources/list を呼び出し、空の一覧が返ってしまいます。
126
+ # https://github.com/modelcontextprotocol/ruby-sdk/blob/v1.8.2/lib/mcp/server.rb
129
127
  server = MCP::Server.new(
130
128
  name: "obsidian-fetch",
131
- version: "0.1.0",
129
+ version: ObsidianFetch::VERSION,
132
130
  tools: [ReadTool, ListTool],
131
+ capabilities: { tools: { listChanged: true } },
133
132
  )
134
133
 
135
134
  # トランスポートを選択してサーバーを起動
@@ -137,31 +136,22 @@ case transport_type
137
136
  when 'streamable-http'
138
137
  require 'puma'
139
138
  require 'rack'
140
-
141
- # Streamable HTTP トランスポートを使用してサーバーを起動
142
139
  transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
143
-
144
- # PumaでRack appとして起動
145
140
  $stdout.sync = true
146
141
  $stderr.sync = true
147
-
148
142
  server_obj = Puma::Server.new(transport) do
149
143
  max_threads 16
150
144
  min_threads 1
151
145
  end
152
146
  server_obj.add_tcp_listener "0.0.0.0", port
153
-
154
147
  STDERR.puts "Streamable HTTP transport started on http://localhost:#{port}"
155
148
  STDERR.puts "Press Ctrl+C to stop"
156
-
157
- # サーバーを起動してメインプロセスをブロックする
158
149
  server_thread = server_obj.run
159
150
  server_thread.join
160
151
  when 'stdio'
161
- # Stdioトランスポートを使用してサーバーを起動
162
152
  transport = MCP::Server::Transports::StdioTransport.new(server)
163
153
  transport.open
164
154
  else
165
155
  STDERR.puts "Unknown transport type: #{transport_type}. Supported: stdio, sse"
166
156
  exit 1
167
- end
157
+ end
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ObsidianFetch
4
+ # ツール呼び出しの結果を保持する
5
+ Result = Struct.new(:text, :error, keyword_init: true) do
6
+ def initialize(text:, error: false)
7
+ super(text: text, error: error)
8
+ end
9
+ end
10
+
11
+ # list ツールの既定の返却件数
12
+ MAX_LIST_SIZE = 20
13
+ # list ツールで指定できる返却件数の上限
14
+ MAX_LIST_LIMIT = 200
15
+
16
+ # ノート名を正規化する
17
+ # ファイル名に使えない文字については除去しない。aliasで設定されている場合もあるため。
18
+ def self.normalize_note_name(note_name)
19
+ note_name.gsub(/[\[\]\#\^|]/, '')
20
+ end
21
+
22
+ PATH_CORRECTION_PREFACE = <<~EOS
23
+ Presumably a path was specified. The process was automatically renamed and processed.
24
+ EOS
25
+
26
+ # --- ツールロジック ---
27
+ def self.read_note(vault, name)
28
+ name = ObsidianFetch.normalize_note_name(name)
29
+ file_pathes = vault.notes[name]
30
+
31
+ preface = ""
32
+ # 名前のノートが見つからないが、nameがパスっぽい場合は、パスを修正したうえでもう一度試す
33
+ if file_pathes.nil? && name.include?('/')
34
+ fixed_name = File.basename(name, '.md')
35
+ file_pathes = vault.notes[fixed_name]
36
+ if file_pathes.nil?
37
+ # 完全一致がない場合のみ、大文字小文字の差異を許容した検索をする
38
+ candidates = case_variant_candidates(vault, fixed_name)
39
+ return fuzzy_match_response(vault, fixed_name, candidates, PATH_CORRECTION_PREFACE) unless candidates.empty?
40
+
41
+ # もしも名前で見つからなければ、リンクにも存在しないか確認する
42
+ link_pathes = vault.links_by_file_name[fixed_name]
43
+ if link_pathes.nil?
44
+ return Result.new(text: note_not_found(name), error: true)
45
+ else
46
+ # リンク先のノートが見つかった場合は、prefaceを追加する
47
+ return Result.new(text: list_links(fixed_name, PATH_CORRECTION_PREFACE, vault), error: true)
48
+ end
49
+ else
50
+ # ノート名が見つかった場合は、prefaceを追加する
51
+ return Result.new(text: PATH_CORRECTION_PREFACE + case_variant_suggestion(vault, fixed_name) + open_file(fixed_name, file_pathes, "", vault))
52
+ end
53
+ end
54
+
55
+ # 名前のノートが存在しない場合
56
+ if file_pathes.nil?
57
+ # 完全一致がない場合のみ、大文字小文字の差異を許容した検索をする
58
+ candidates = case_variant_candidates(vault, name)
59
+ return fuzzy_match_response(vault, name, candidates, preface) unless candidates.empty?
60
+
61
+ return Result.new(text: note_not_found(name), error: true) if vault.links_by_file_name[name].nil?
62
+ return Result.new(text: list_links(name, preface, vault), error: true)
63
+ end
64
+
65
+ Result.new(text: case_variant_suggestion(vault, name) + open_file(name, file_pathes, preface, vault))
66
+ end
67
+
68
+ def self.list_notes(vault, name, limit: MAX_LIST_SIZE)
69
+ unless limit.is_a?(Integer) && limit >= 1 && limit <= MAX_LIST_LIMIT
70
+ return Result.new(text: <<~EOS, error: true)
71
+ The limit must be an integer between 1 and #{MAX_LIST_LIMIT}.
72
+ EOS
73
+ end
74
+
75
+ name = ObsidianFetch.normalize_note_name(name)
76
+ split_name = name.split(/[\t \u3000]+/)
77
+ # 空白文字で検索された場合は失敗した旨を返す
78
+ # 仮に全ノートからランダムに limit 件返してしまうと、LLMが誤って呼び出した場合に偽の関連性を持ってしまうため
79
+ if split_name.empty?
80
+ return Result.new(text: <<~EOS, error: true)
81
+ It cannot be listed in a blank string.
82
+ EOS
83
+ end
84
+ matched_notes = vault.notes.filter_map do |note_name, file_pathes|
85
+ match_note(vault, note_name, file_pathes, split_name)
86
+ end
87
+ # 名前で検索したが見つからない場合
88
+ if matched_notes.empty?
89
+ has_found_link = vault.links_by_file_name[name] && !vault.links_by_file_name[name].empty?
90
+ return Result.new(text: <<~EOS, error: true) unless has_found_link
91
+ Note not found: #{name}
92
+ Search again with a substring or a string with a different notation.
93
+ EOS
94
+ return Result.new(text: <<~EOS, error: true)
95
+ Note not found: #{name}
96
+ However, I found other notes linked to this note.
97
+ #{sort_case_insensitive(vault.links_by_file_name[name].map { |file_path| File.basename(file_path, '.md') }).map { |note_name| "- #{note_name}" }.join("\n")}
98
+ EOS
99
+ end
100
+ # マッチしたノートの数が limit より多い場合は、優先順位の順に枠を埋める
101
+ # truncate されるグループは先頭と末尾を出す
102
+ total = matched_notes.size
103
+ by_name = matched_notes.count { |entry| entry[:matched_by_name] }
104
+ tiers = matched_notes.group_by { |entry| entry[:distance] }.keys.sort.map do |distance|
105
+ matched_notes.select { |entry| entry[:distance] == distance }.sort_by { |entry| [entry[:note_name].downcase, entry[:note_name]] }
106
+ end
107
+
108
+ segments = []
109
+ remaining = limit
110
+ tiers.each do |tier|
111
+ break if remaining.zero?
112
+
113
+ if tier.size <= remaining
114
+ segments << {entries: tier, omitted_after: 0}
115
+ remaining -= tier.size
116
+ elsif remaining < 3
117
+ segments << {entries: tier.first(remaining), omitted_after: tier.size - remaining}
118
+ remaining = 0
119
+ else
120
+ head_size = (remaining * 2) / 3
121
+ tail_size = remaining - head_size
122
+ segments << {entries: tier.first(head_size), omitted_after: tier.size - remaining}
123
+ segments << {entries: tier.last(tail_size), omitted_after: 0}
124
+ remaining = 0
125
+ end
126
+ end
127
+
128
+ shown = segments.sum { |segment| segment[:entries].size }
129
+ preface = "#{total} notes matched '#{name}'"
130
+ preface += " (#{by_name} by note name, #{total - by_name} by folder name)" if total > by_name
131
+ preface += ".\n"
132
+ if shown < total
133
+ preface += "I will show you #{shown} of them. To see the rest, narrow the search or raise the limit.\n"
134
+ end
135
+ preface += "Notes matching '#{name}' are as follows.\n"
136
+ lines = []
137
+ segments.each do |segment|
138
+ lines += segment[:entries].map do |entry|
139
+ if entry[:matched_by_name]
140
+ "- #{entry[:note_name]}"
141
+ else
142
+ "- #{entry[:note_name]} (#{entry[:display_folder]})"
143
+ end
144
+ end
145
+ lines << "... (#{segment[:omitted_after]} notes omitted) ..." if segment[:omitted_after].positive?
146
+ end
147
+ Result.new(text: preface + lines.join("\n") + "\n")
148
+ end
149
+
150
+ # ノート1件について検索語のマッチを判定し、マッチしていれば情報を返す
151
+ # 距離はファイルからの距離で、ノート名は距離0、直近の親フォルダは距離1、その上が距離2とする
152
+ # 各語の最も近いマッチ位置のうち、最も遠いものがそのノートの距離になる
153
+ def self.match_note(vault, note_name, file_pathes, words)
154
+ lowered_words = words.map(&:downcase)
155
+ best = nil
156
+
157
+ file_pathes.each do |file_path|
158
+ vault_name, rel_path = relative_path(vault, file_path)
159
+ folders = rel_path.split('/')[0...-1]
160
+
161
+ distances = lowered_words.map do |word|
162
+ distance = note_name.downcase.include?(word) ? 0 : nil
163
+ folders.each_with_index do |folder, index|
164
+ next unless folder.downcase.include?(word)
165
+ candidate = folders.size - index
166
+ distance = candidate if distance.nil? || candidate < distance
167
+ end
168
+ distance
169
+ end
170
+ next if distances.any?(&:nil?)
171
+
172
+ display_folder = folders.join('/')
173
+ display_folder = File.join(vault_name, display_folder) if vault.vault_pathes.size > 1 && vault_name
174
+
175
+ entry = {
176
+ note_name: note_name,
177
+ distance: distances.max,
178
+ matched_by_name: distances.max.zero?,
179
+ display_folder: display_folder,
180
+ }
181
+ best = entry if best.nil? || entry[:distance] < best[:distance]
182
+ end
183
+
184
+ best
185
+ end
186
+
187
+ # 保管庫ルートからの相対パスと、その保管庫名を返す
188
+ def self.relative_path(vault, file_path)
189
+ vault.vault_pathes.each do |vault_path|
190
+ pattern = /\A#{Regexp.escape(vault_path)}\/+/
191
+ return [File.basename(vault_path), file_path.sub(pattern, '')] if file_path.match?(pattern)
192
+ end
193
+ [nil, File.basename(file_path)]
194
+ end
195
+
196
+ # --- ヘルパーメソッド ---
197
+ def self.note_not_found(name)
198
+ <<~EOS
199
+ Note not found: #{name}
200
+ EOS
201
+ end
202
+
203
+ # 大文字小文字は揃えて比較し、同値は原名の ASCII 順でタイブレークする
204
+ def self.sort_case_insensitive(values)
205
+ values.sort_by { |value| [value.downcase, value] }
206
+ end
207
+
208
+ # 完全一致を除いた、大文字小文字の差異だけのキーをノート名順で返す
209
+ def self.case_variant_candidates(vault, target)
210
+ sort_case_insensitive(vault.notes.keys.reject { |note_name| note_name == target }.select { |note_name| note_name.downcase == target.downcase })
211
+ end
212
+
213
+ # 完全一致を読むときに、揺らぎマッチしたノート名のサジェストを組み立てる
214
+ def self.case_variant_suggestion(vault, target)
215
+ candidates = case_variant_candidates(vault, target)
216
+ return "" if candidates.empty?
217
+
218
+ if candidates.size == 1
219
+ "There is also a note with the same name in different capitalization:\n- #{candidates.first}\n\n"
220
+ else
221
+ "There are also notes with the same name in different capitalization:\n" +
222
+ candidates.map { |note_name| "- #{note_name}" }.join("\n") + "\n\n"
223
+ end
224
+ end
225
+
226
+ # 揺らぎマッチの応答を組み立てる
227
+ # 候補が1件のときはその内容を読み、複数のときは候補名の一覧を返す
228
+ def self.fuzzy_match_response(vault, target, candidates, preface)
229
+ if candidates.size == 1
230
+ note_name = candidates.first
231
+ message = "There is no note named '#{target}'. The following note has the same name with different capitalization:\n- #{note_name}\n\n"
232
+ Result.new(text: preface + message + open_file(note_name, vault.notes[note_name], "", vault), error: false)
233
+ else
234
+ message = "There is no note named '#{target}'. The following notes have the same name with different capitalization:\n" +
235
+ candidates.map { |note_name| "- #{note_name}" }.join("\n") + "\n"
236
+ Result.new(text: preface + message, error: false)
237
+ end
238
+ end
239
+
240
+ def self.list_links(name, preface, vault)
241
+ <<~EOS
242
+ #{preface}
243
+ Note not found: #{name}
244
+ However, I found other notes linked to this note.
245
+ #{sort_case_insensitive(vault.links_by_file_name[name].map { |file_path| File.basename(file_path, '.md') }).map { |note_name| "- #{note_name}" }.join("\n")}
246
+ EOS
247
+ end
248
+
249
+ def self.open_file(name, file_pathes, preface, vault)
250
+ # 複数のファイルがある場合は、---とファイル名で区切って返す
251
+ result = sort_case_insensitive(file_pathes).map do |file_path|
252
+ content = open(file_path) { |f| f.read.force_encoding('UTF-8') }
253
+ link_notes = if vault.links_by_file_path[file_path].nil?
254
+ ""
255
+ else
256
+ <<~EOS
257
+ This note is linked by the following notes:
258
+ #{sort_case_insensitive(vault.links_by_file_path[file_path].map { |linked_path| File.basename(linked_path, '.md') }).map { |note_name| "- #{note_name}" }.join("\n")}
259
+ EOS
260
+ end
261
+ metadata = <<~EOS
262
+ The contents of the note '#{name}' is as follows.
263
+ #{link_notes}
264
+ ---
265
+ EOS
266
+ metadata + content
267
+ end.join("\n\n---\n\n")
268
+ preface + result
269
+ end
270
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ObsidianFetch
4
+ # Vaultスナップショットを表現するイミュータブルなデータ構造
5
+ class Vault
6
+ attr_reader :vault_pathes
7
+ attr_reader :notes
8
+ attr_reader :links_by_file_path
9
+ attr_reader :links_by_file_name
10
+
11
+ # @param vault_pathes [Array<String>] 保管庫パスのリスト
12
+ # @param notes [Hash{String => Array<String>}] {ノート名 → [ファイルパス]}
13
+ # @param links_by_file_path [Hash{String => Array<String>}] {ファイルパス → [逆引きパス]}
14
+ # @param links_by_file_name [Hash{String => Array<String>}] {ノート名 → [逆引きパス]}
15
+ def initialize(vault_pathes, notes:, links_by_file_path:, links_by_file_name:)
16
+ @vault_pathes = vault_pathes
17
+ @notes = notes
18
+ @links_by_file_path = links_by_file_path
19
+ @links_by_file_name = links_by_file_name
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module ObsidianFetch
6
+ # Vaultスナップショットのライフサイクルを管理
7
+ # ファイル変更を検知し、Vaultを再構築する
8
+ class VaultManager
9
+
10
+ def initialize(vault_pathes)
11
+ @vault_pathes = vault_pathes
12
+ scan!
13
+ end
14
+
15
+ # 最新のVaultを返す。変更があれば自動的に再スキャンする。
16
+ def vault
17
+ refresh_if_changed
18
+ @vault
19
+ end
20
+
21
+ private
22
+
23
+ # 変更があれば再スキャン
24
+ def refresh_if_changed
25
+ return unless changed?
26
+ scan!
27
+ end
28
+
29
+ # 再スキャン: Vaultを再構築し、内部状態を更新
30
+ def scan!
31
+ @last_scan_time = Time.now
32
+ @notes_cache = collect_notes
33
+ @vault = build_vault
34
+ @known_paths = collect_pathes.to_set
35
+ @known_mtimes = {}
36
+ @known_sizes = {}
37
+ @known_paths.each do |path|
38
+ stat = File.stat(path) rescue nil
39
+ @known_mtimes[path] = stat&.mtime&.to_f
40
+ @known_sizes[path] = stat&.size
41
+ end
42
+ end
43
+
44
+ # 変更検知
45
+ # チェックサムは計算しない(mtimeとサイズだけで変更を検知)
46
+ # パスごとに stat を1回だけ実行し、mtime と size を両方取得
47
+ # mtime は浮動小数点で比較(マイクロ秒の差も検知)
48
+ # 1. 既知のパスがファイルシステムに存在しない → 削除
49
+ # 2. mtime または size が前回と異なる → 変更
50
+ # 3. 新しい.mdファイルが追加されている → 追加
51
+ def changed?
52
+ current_paths = collect_pathes
53
+
54
+ # 追加検知
55
+ if (current_paths.to_set - @known_paths).any?
56
+ return true
57
+ end
58
+
59
+ # 削除検知と変更検知を1パスで実行
60
+ @known_paths.each do |path|
61
+ stat = File.stat(path) rescue nil
62
+ unless stat
63
+ return true # 既知のパスが存在しなくなった = 削除
64
+ end
65
+ previous_mtime = @known_mtimes[path]
66
+ previous_size = @known_sizes[path]
67
+ if stat.mtime.to_f != previous_mtime || stat.size != previous_size
68
+ return true
69
+ end
70
+ end
71
+
72
+ false
73
+ end
74
+
75
+ # .mdファイルのパスを収集
76
+ def collect_pathes
77
+ @vault_pathes.flat_map do |vault_path|
78
+ Dir.glob("#{vault_path}/**/*.md")
79
+ end
80
+ end
81
+
82
+ # ノート名・aliasの抽出
83
+ # 戻り値: {ノート名 → [ファイルパス]} のハッシュ
84
+ def collect_notes
85
+ # ノートを整理するための処理
86
+ notes = {}
87
+ collect_pathes.each do |file_path|
88
+ file_name = File.basename(file_path, '.md')
89
+ # ノート名を正規化する
90
+ # ファイル名に使えない文字については除去しない。aliasで設定されている場合もあるため。
91
+ file_name = ObsidianFetch.normalize_note_name(file_name)
92
+ notes[file_name] ||= []
93
+ notes[file_name] << file_path
94
+
95
+ content = open(file_path) { |f| f.read.force_encoding('UTF-8') }
96
+ frontmatter_str = content.match(/\A---(.*?\n)---/m)&.[](1)
97
+ if frontmatter_str
98
+ begin
99
+ frontmatter = YAML.safe_load(frontmatter_str,
100
+ symbolize_names: true, aliases: true, permitted_classes: [Date])
101
+ rescue Psych::SyntaxError => e
102
+ puts "YAML syntax error in #{file_path}: #{e.message}"
103
+ next
104
+ end
105
+ Array(frontmatter&.[](:aliases) || []).each do |alias_name|
106
+ # もしaliasが設定されていれば、それも追加する
107
+ alias_name = ObsidianFetch.normalize_note_name(alias_name)
108
+ notes[alias_name] ||= []
109
+ notes[alias_name] << file_path
110
+ end
111
+ end
112
+ end
113
+ notes.each { |_name, file_pathes| file_pathes.uniq! }
114
+ notes
115
+ end
116
+
117
+ # [[link]]と[link](path)の解析
118
+ # 戻り値: [links_by_file_path, links_by_file_name] の2要素配列
119
+ def collect_links
120
+ links_by_file_path = {}
121
+ links_by_file_name = {}
122
+
123
+ # リンクを整理するための処理
124
+ # リンクを整理するためにノート名のリストを使用するため、ここで処理する
125
+ collect_pathes.each do |file_path|
126
+ file_name = File.basename(file_path, '.md')
127
+ file_name = ObsidianFetch.normalize_note_name(file_name)
128
+ content = open(file_path) { |f| f.read.force_encoding('UTF-8') }
129
+ # もしリンクが設定されていれば、linksに追加する
130
+ # [[link]]と[[link|displayname]]の場合
131
+ # linkに.mdが付いている場合、付いていない場合両方を考慮する
132
+ # TODO : linkにディレクトリ名が着くことがあるので、それを除去する
133
+ content.scan(/\[\[(.*?)(?:\|.*)?\]\]/) do |match|
134
+ link_name = match[0]
135
+ link_name = link_name.sub(/\.md$/, '') # .mdを削除
136
+ # #、^以降の文字列を削除
137
+ link_name = link_name.sub(/#.*$/, '')
138
+ link_name = ObsidianFetch.normalize_note_name(link_name)
139
+ next if link_name == file_name # 自分自身をリンクしている場合は無視する
140
+ # 同名のノートがある場合に、どこにリンクしているかを処理するのは結構難しい
141
+ # ここでは簡単のため、同名のノートがある場合はすべてのファイルをリンク先として追加する
142
+ # ただし、aliasは除外する
143
+ links_by_file_name[link_name] ||= []
144
+ links_by_file_name[link_name] << file_path
145
+ linked_candidates = (@notes_cache[link_name] || []).filter { |path| path.include?(link_name + '.md') }
146
+ linked_candidates.each do |linked_candidate|
147
+ links_by_file_path[linked_candidate] ||= []
148
+ links_by_file_path[linked_candidate] << file_path
149
+ end
150
+ end
151
+
152
+ # [displayname](path)の場合
153
+ # pathの末尾に.mdが付いている場合、付いていない場合両方を考慮する
154
+ content.scan(/\[(.*?)\]\((?!\[\[)(.*?)(?<!\]\])\)/) do |match|
155
+ path = match[1]
156
+ # `(.+)://`から始まる場合は除外する
157
+ next if path =~ /^[a-z]+:\/\// # 外部リンク
158
+ # #、^以降の文字列を削除
159
+ path = path.sub(/#.*$/, '')
160
+ path = path.sub(/\.md$/, '') # .mdを削除
161
+ link_name = File.basename(path)
162
+ link_name = ObsidianFetch.normalize_note_name(link_name)
163
+ links_by_file_name[link_name] ||= []
164
+ links_by_file_name[link_name] << file_path
165
+ path_from_vault = File.join(File.dirname(file_path), path) + '.md'
166
+ # Obsidianはかなり賢くて、リンク先のファイルが無い場合には、その配下のファイルを探してくれる
167
+ # 今回の実装ではそこまで考慮せず、pathを信用する
168
+ links_by_file_path[path_from_vault] ||= []
169
+ links_by_file_path[path_from_vault] << file_path
170
+ end
171
+ end
172
+
173
+ links_by_file_name.each { |_file_name, file_pathes| file_pathes.uniq! }
174
+ links_by_file_path.each { |_file_path, file_pathes| file_pathes.uniq! }
175
+ [links_by_file_path, links_by_file_name]
176
+ end
177
+
178
+ # Vaultスナップショットを生成
179
+ def build_vault
180
+ links_by_file_path, links_by_file_name = collect_links
181
+ Vault.new(@vault_pathes,
182
+ notes: @notes_cache,
183
+ links_by_file_path: links_by_file_path,
184
+ links_by_file_name: links_by_file_name)
185
+ end
186
+ end
187
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ObsidianFetch
4
- VERSION = "1.1.2"
4
+ VERSION = "1.3.0"
5
5
  end
@@ -4,247 +4,10 @@ require 'yaml'
4
4
  require 'date'
5
5
 
6
6
  require_relative "obsidian_fetch/version"
7
+ require_relative "obsidian_fetch/vault"
8
+ require_relative "obsidian_fetch/vault_manager"
9
+ require_relative "obsidian_fetch/logic"
7
10
 
8
11
  module ObsidianFetch
9
12
  class Error < StandardError; end
10
-
11
- class Vault
12
- attr_reader :notes, :links_by_file_path, :links_by_file_name
13
-
14
- def initialize vault_pathes
15
- @vault_pathes = vault_pathes
16
-
17
- # key: note_name, value: [file_pathes]
18
- @notes = {}
19
-
20
- # key: file_path, value: [file_pathes]
21
- @links_by_file_path = {}
22
-
23
- # ノートが見つからなかった場合に被リンクを表示するためのハッシュ
24
- # key: note_name, value: [file_pathes]
25
- @links_by_file_name = {}
26
-
27
- collect_pathes()
28
- collect_notes()
29
- collect_links()
30
- end
31
-
32
- private
33
-
34
- def collect_pathes
35
- @all_pathes = @vault_pathes.flat_map do |vault_path|
36
- Dir.glob("#{vault_path}/**/*.md")
37
- end
38
- end
39
-
40
- # ノート名を正規化する
41
- # ファイル名に使えない文字については除去しない。aliasで設定されている場合もあるため。
42
- def self.normalize_note_name(note_name)
43
- # ノート名を小文字に変換し、リンクに使えない文字を除去する
44
- note_name.gsub(/[\[\]#\^\|]/, '')
45
- end
46
-
47
- def collect_notes
48
- # ノート名を整理するための処理
49
- @all_pathes.each do |file_path|
50
- file_name = File.basename(file_path, '.md')
51
- file_name = Vault.normalize_note_name(file_name)
52
- @notes[file_name] ||= []
53
- @notes[file_name] << file_path
54
- content = open(file_path) { |f| f.read.force_encoding('UTF-8') }
55
- # もしaliasが設定されていれば、それも追加する
56
- frontmatter_str = content.match(/\A---(.*?\n)---/m)&.[](1)
57
- if frontmatter_str
58
- begin
59
- frontmatter = YAML.safe_load(frontmatter_str, symbolize_names: true, aliases: true, permitted_classes: [Date])
60
- rescue Psych::SyntaxError => e
61
- puts "YAML syntax error in #{file_path}: #{e.message}"
62
- next
63
- end
64
- Array(frontmatter&.[](:aliases) || []).each do |alias_name|
65
- alias_name = Vault.normalize_note_name(alias_name)
66
- @notes[alias_name] ||= []
67
- @notes[alias_name] << file_path
68
- end
69
- end
70
- end
71
- @notes.each{|_name, file_pathes| file_pathes.uniq!}
72
- end
73
-
74
- def collect_links
75
- # リンクを整理するための処理
76
- # リンクを整理するためにノート名のリストを使用するため、ここで処理する
77
- @all_pathes.each do |file_path|
78
- file_name = File.basename(file_path, '.md')
79
- file_name = Vault.normalize_note_name(file_name)
80
- content = open(file_path) { |f| f.read.force_encoding('UTF-8') }
81
- # もしリンクが設定されていれば、linksに追加する
82
- # [[link]]と[[link|displayname]]の場合
83
- # linkに.mdが付いている場合、付いていない場合両方を考慮する
84
- # TODO : linkにディレクトリ名が着くことがあるので、それを除去する
85
- content.scan(/\[\[(.*?)(?:\|.*)?\]\]/) do |match|
86
- link_name = match[0]
87
- link_name = link_name.sub(/\.md$/, '') # .mdを削除
88
- # #、^以降の文字列を削除
89
- link_name = link_name.sub(/#.*$/, '')
90
- link_name = Vault.normalize_note_name(link_name)
91
- next if link_name == file_name # 自分自身をリンクしている場合は無視する
92
- @links_by_file_name[link_name] ||= []
93
- @links_by_file_name[link_name] << file_path
94
- # 同名のノートがある場合に、どこにリンクしているかを処理するのは結構難しい
95
- # ここでは簡単のため、同名のノートがある場合はすべてのファイルをリンク先として追加する
96
- # ただし、aliasは除外する
97
- linked_candidates = (@notes[link_name] || []).filter{|path| path.include?(link_name + '.md') }
98
- linked_candidates.each do |linked_candidate|
99
- @links_by_file_path[linked_candidate] ||= []
100
- @links_by_file_path[linked_candidate] << file_path
101
- end
102
- end
103
- # [displayname](path)の場合
104
- # pathの末尾に.mdが付いている場合、付いていない場合両方を考慮する
105
- content.scan(/\[(.*?)\]\((?!\[\[)(.*?)(?<!\]\])\)/) do |match|
106
- path = match[1]
107
- # `(.+)://`から始まる場合は除外する
108
- next if path =~ /^[a-z]+:\/\// # 外部リンク
109
- # #、^以降の文字列を削除
110
- path = path.sub(/#.*$/, '')
111
- path = path.sub(/\.md$/, '') # .mdを削除
112
- link_name = File.basename(path)
113
- link_name = Vault.normalize_note_name(link_name)
114
- @links_by_file_name[link_name] ||= []
115
- @links_by_file_name[link_name] << file_path
116
- # Obsidianはかなり賢くて、リンク先のファイルが無い場合には、その配下のファイルを探してくれる
117
- # 今回の実装ではそこまで考慮せず、pathを信用する
118
- path_from_vault = File.join(File.dirname(file_path), path) + '.md'
119
- @links_by_file_path[path_from_vault] ||= []
120
- @links_by_file_path[path_from_vault] << file_path
121
- end
122
- end
123
- @links_by_file_name.each do |_file_name, file_pathes|
124
- file_pathes.uniq!
125
- end
126
- @links_by_file_path.each do |_file_path, file_pathes|
127
- file_pathes.uniq!
128
- end
129
- end
130
-
131
- public
132
-
133
- def tool_read name
134
- name = Vault.normalize_note_name(name)
135
- file_pathes = @notes[name]
136
-
137
- # 名前のノートが見つからないが、nameがパスっぽい場合は、パスを修正したうえでもう一度試す
138
- preface = ""
139
- if file_pathes.nil? && name.include?('/')
140
- fixed_name = File.basename(name, '.md')
141
- file_pathes = @notes[fixed_name]
142
- if file_pathes.nil?
143
- # もしも名前で見つからなければ、リンクにも存在しないか確認する
144
- link_pathes = @links_by_file_name[fixed_name]
145
- if link_pathes.nil?
146
- return note_not_found(name)
147
- else
148
- # リンク先のノートが見つかった場合は、prefaceを追加する
149
- preface = <<~EOS
150
- Presumably a path was specified. The process was automatically renamed and processed.
151
- EOS
152
- return list_links(fixed_name, preface)
153
- end
154
- else
155
- # ノート名が見つかった場合は、prefaceを追加する
156
- preface = <<~EOS
157
- Presumably a path was specified. The process was automatically renamed and processed.
158
- EOS
159
- return open_file(fixed_name, file_pathes, preface)
160
- end
161
- end
162
-
163
- # 名前のノートが存在しない場合
164
- if file_pathes.nil?
165
- return note_not_found(name) if @links_by_file_name[name].nil?
166
- return list_links(name, preface)
167
- end
168
-
169
- open_file(name, file_pathes, preface)
170
- end
171
-
172
- private def note_not_found name
173
- return <<~EOS
174
- Note not found: #{name}
175
- EOS
176
- end
177
-
178
- private def list_links name, preface
179
- return <<~EOS
180
- #{preface}
181
- Note not found: #{name}
182
- However, I found other notes linked to this note.
183
- #{@links_by_file_name[name].shuffle.map { |file_path| "- #{File.basename(file_path, '.md')}" }.join("\n")}
184
- EOS
185
- end
186
-
187
- private def open_file name, file_pathes, preface
188
- # 複数のファイルがある場合は、---とファイル名で区切って返す
189
- result = file_pathes.map do |file_path|
190
- content = open(file_path) { |f| f.read.force_encoding('UTF-8') }
191
- link_notes = if @links_by_file_path[file_path].nil?
192
- ""
193
- else
194
- <<~EOS
195
- This note is linked by the following notes:
196
- #{(@links_by_file_path[file_path] || []).shuffle.map { |file_path| "- #{File.basename(file_path, '.md')}" }.join("\n")}
197
- EOS
198
- end
199
- metadata = <<~EOS
200
- The contents of the note '#{name}' is as follows.
201
- #{link_notes}
202
- ---
203
- EOS
204
- metadata + content
205
- end.join("\n\n---\n\n")
206
- preface + result
207
- end
208
-
209
- MAX_LIST_SIZE = 20
210
- def tool_list name
211
- name = Vault.normalize_note_name(name)
212
- split_name = name.split(/[\s ]+/)
213
- # 空白文字で検索された場合は失敗した旨を返す
214
- # 仮に全ノートからランダムなMAX_LIST_SIZEを返してしまうと、LLMが誤って呼び出した場合に偽の関連性を持ってしまうため
215
- if split_name.empty?
216
- return <<~EOS
217
- It cannot be listed in a blank string.
218
- EOS
219
- end
220
- matched_notes = @notes.select do |note_name, _file_pathes|
221
- split_name.map {|name_part| note_name.include?(name_part) }.all?
222
- end
223
- # 名前で検索したが見つからない場合
224
- if matched_notes.empty?
225
- has_found_link = @links_by_file_name[name] && !@links_by_file_name[name].empty?
226
- return <<~EOS unless has_found_link
227
- Note not found: #{name}
228
- Search again with a substring or a string with a different notation.
229
- EOS
230
- return <<~EOS
231
- Note not found: #{name}
232
- However, I found other notes linked to this note.
233
- #{@links_by_file_name[name].shuffle.map { |file_path| "- #{File.basename(file_path, '.md')}" }.join("\n")}
234
- EOS
235
- end
236
- # マッチした名前の数が多すぎる場合は、ランダムにMAX_LIST_SIZE個選ぶ
237
- preface = "Notes matching '#{name}' are as follows.\n"
238
- if matched_notes.size > MAX_LIST_SIZE
239
- matched_notes = matched_notes.to_a.sample(MAX_LIST_SIZE).to_h
240
- preface = "Too many notes matched. I will show you only #{MAX_LIST_SIZE} of them.\n" + preface
241
- end
242
- # マッチした名前のリストで返す
243
- list = matched_notes.keys.shuffle.map do |note_name|
244
- "- #{note_name}"
245
- end.join("\n")
246
- preface + list
247
- end
248
- end
249
-
250
13
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: obsidian_fetch
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.2
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - sou7
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: 0.14.0
18
+ version: '1.8'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: 0.14.0
25
+ version: '1.8'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: rack
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -58,6 +58,7 @@ executables:
58
58
  extensions: []
59
59
  extra_rdoc_files: []
60
60
  files:
61
+ - AGENTS.md
61
62
  - CHANGELOG.md
62
63
  - LICENSE.txt
63
64
  - README.md
@@ -66,6 +67,9 @@ files:
66
67
  - exe/obsidian_fetch
67
68
  - lib/exe.rb
68
69
  - lib/obsidian_fetch.rb
70
+ - lib/obsidian_fetch/logic.rb
71
+ - lib/obsidian_fetch/vault.rb
72
+ - lib/obsidian_fetch/vault_manager.rb
69
73
  - lib/obsidian_fetch/version.rb
70
74
  - sig/obsidian_fetch.rbs
71
75
  homepage: https://ob.sou7.io/2025-04/week17/obsidian_fetch
@@ -82,7 +86,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
82
86
  requirements:
83
87
  - - ">="
84
88
  - !ruby/object:Gem::Version
85
- version: 3.1.0
89
+ version: 3.3.0
86
90
  required_rubygems_version: !ruby/object:Gem::Requirement
87
91
  requirements:
88
92
  - - ">="