obsidian_fetch 1.1.2 → 1.2.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: f05f834b38c590fcbccce689668144c14f442bf7ff08899e04f2feb1f807eb95
4
+ data.tar.gz: cc9d23ced95dd85719d0f0666a3363929e40ff8b2983962cc58930e3d2793157
5
5
  SHA512:
6
- metadata.gz: 31e95b02fc5d2937e22b622e4629833512018e5ca66de2a168b7eb0c212a17b35e9863394105305b238d70bf4bcd2ca7574f382353fcc1b1289af7c778faa5b6
7
- data.tar.gz: d2e6cb6b19c502f80e00784b45fe47d1daf1933048acaa7135f94fa2a4b09c919580640f771c50c1ad9ee4ca24d7c7374b05c932e88d7f00637f8aaae5ed7a43
6
+ metadata.gz: 366348161c4e8a2352c9a715787f9f86ebb96d0aee0878d83720a4da623d3ab378878e5dd4c0c86ba9dc1ef92421c27f162c40dcbbfadf0ea033c35d5cf6fc19
7
+ data.tar.gz: 731c8660944590029ab2383da67fb2cfc8499bc89ef7bcde7460798b62651df76e7ec104f79165048a6d20a76760c93f3cf52c271a8cb84d5d4bcf4b5252d004
data/AGENTS.md ADDED
@@ -0,0 +1,5 @@
1
+ # このリポジトリにおけるエージェントの行動指針
2
+
3
+ ## テストの実行
4
+
5
+ テストは `bundle exec rake test` で実行する。
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,18 +93,13 @@ 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
104
  description "Search for files with matching names partially."
111
105
  input_schema(
@@ -116,19 +110,16 @@ class ListTool < MCP::Tool
116
110
  )
117
111
 
118
112
  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
+ return MCP::Tool::Response.new([{ type: "text", text: "Name must be a string" }], error: true) unless name.is_a?(String)
114
+ result = ObsidianFetch.list_notes($manager.vault, name)
115
+ MCP::Tool::Response.new([{ type: "text", text: result.text }], error: result.error)
125
116
  end
126
117
  end
127
118
 
128
119
  # MCPサーバーの初期化
129
120
  server = MCP::Server.new(
130
121
  name: "obsidian-fetch",
131
- version: "0.1.0",
122
+ version: ObsidianFetch::VERSION,
132
123
  tools: [ReadTool, ListTool],
133
124
  )
134
125
 
@@ -137,31 +128,22 @@ case transport_type
137
128
  when 'streamable-http'
138
129
  require 'puma'
139
130
  require 'rack'
140
-
141
- # Streamable HTTP トランスポートを使用してサーバーを起動
142
131
  transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
143
-
144
- # PumaでRack appとして起動
145
132
  $stdout.sync = true
146
133
  $stderr.sync = true
147
-
148
134
  server_obj = Puma::Server.new(transport) do
149
135
  max_threads 16
150
136
  min_threads 1
151
137
  end
152
138
  server_obj.add_tcp_listener "0.0.0.0", port
153
-
154
139
  STDERR.puts "Streamable HTTP transport started on http://localhost:#{port}"
155
140
  STDERR.puts "Press Ctrl+C to stop"
156
-
157
- # サーバーを起動してメインプロセスをブロックする
158
141
  server_thread = server_obj.run
159
142
  server_thread.join
160
143
  when 'stdio'
161
- # Stdioトランスポートを使用してサーバーを起動
162
144
  transport = MCP::Server::Transports::StdioTransport.new(server)
163
145
  transport.open
164
146
  else
165
147
  STDERR.puts "Unknown transport type: #{transport_type}. Supported: stdio, sse"
166
148
  exit 1
167
- end
149
+ end
@@ -0,0 +1,133 @@
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
+ MAX_LIST_SIZE = 20
12
+
13
+ # ノート名を正規化する
14
+ # ファイル名に使えない文字については除去しない。aliasで設定されている場合もあるため。
15
+ def self.normalize_note_name(note_name)
16
+ note_name.gsub(/[\[\]\#\^|]/, '')
17
+ end
18
+
19
+ # --- ツールロジック ---
20
+ def self.read_note(vault, name)
21
+ name = ObsidianFetch.normalize_note_name(name)
22
+ file_pathes = vault.notes[name]
23
+
24
+ preface = ""
25
+ # 名前のノートが見つからないが、nameがパスっぽい場合は、パスを修正したうえでもう一度試す
26
+ if file_pathes.nil? && name.include?('/')
27
+ fixed_name = File.basename(name, '.md')
28
+ file_pathes = vault.notes[fixed_name]
29
+ if file_pathes.nil?
30
+ # もしも名前で見つからなければ、リンクにも存在しないか確認する
31
+ link_pathes = vault.links_by_file_name[fixed_name]
32
+ if link_pathes.nil?
33
+ return Result.new(text: note_not_found(name), error: true)
34
+ else
35
+ # リンク先のノートが見つかった場合は、prefaceを追加する
36
+ preface = <<~EOS
37
+ Presumably a path was specified. The process was automatically renamed and processed.
38
+ EOS
39
+ return Result.new(text: list_links(fixed_name, preface, vault), error: true)
40
+ end
41
+ else
42
+ # ノート名が見つかった場合は、prefaceを追加する
43
+ preface = <<~EOS
44
+ Presumably a path was specified. The process was automatically renamed and processed.
45
+ EOS
46
+ return Result.new(text: open_file(fixed_name, file_pathes, preface, vault))
47
+ end
48
+ end
49
+
50
+ # 名前のノートが存在しない場合
51
+ if file_pathes.nil?
52
+ return Result.new(text: note_not_found(name), error: true) if vault.links_by_file_name[name].nil?
53
+ return Result.new(text: list_links(name, preface, vault), error: true)
54
+ end
55
+
56
+ Result.new(text: open_file(name, file_pathes, preface, vault))
57
+ end
58
+
59
+ def self.list_notes(vault, name)
60
+ name = ObsidianFetch.normalize_note_name(name)
61
+ split_name = name.split(/[\t \u3000]+/)
62
+ # 空白文字で検索された場合は失敗した旨を返す
63
+ # 仮に全ノートからランダムなMAX_LIST_SIZEを返してしまうと、LLMが誤って呼び出した場合に偽の関連性を持ってしまうため
64
+ if split_name.empty?
65
+ return Result.new(text: <<~EOS, error: true)
66
+ It cannot be listed in a blank string.
67
+ EOS
68
+ end
69
+ matched_notes = vault.notes.select do |note_name, _file_pathes|
70
+ split_name.map { |name_part| note_name.include?(name_part) }.all?
71
+ end
72
+ # 名前で検索したが見つからない場合
73
+ if matched_notes.empty?
74
+ has_found_link = vault.links_by_file_name[name] && !vault.links_by_file_name[name].empty?
75
+ return Result.new(text: <<~EOS, error: true) unless has_found_link
76
+ Note not found: #{name}
77
+ Search again with a substring or a string with a different notation.
78
+ EOS
79
+ return Result.new(text: <<~EOS, error: true)
80
+ Note not found: #{name}
81
+ However, I found other notes linked to this note.
82
+ #{vault.links_by_file_name[name].shuffle.map { |file_path| "- #{File.basename(file_path, '.md')}" }.join("\n")}
83
+ EOS
84
+ end
85
+ # マッチした名前の数が多すぎる場合は、ランダムにMAX_LIST_SIZE個選ぶ
86
+ preface = "Notes matching '#{name}' are as follows.\n"
87
+ if matched_notes.size > MAX_LIST_SIZE
88
+ matched_notes = matched_notes.to_a.sample(MAX_LIST_SIZE).to_h
89
+ preface = "Too many notes matched. I will show you only #{MAX_LIST_SIZE} of them.\n" + preface
90
+ end
91
+ # マッチした名前のリストで返す
92
+ list = matched_notes.keys.shuffle.map { |note_name| "- #{note_name}" }.join("\n")
93
+ Result.new(text: preface + list)
94
+ end
95
+
96
+ # --- ヘルパーメソッド ---
97
+ def self.note_not_found(name)
98
+ <<~EOS
99
+ Note not found: #{name}
100
+ EOS
101
+ end
102
+
103
+ def self.list_links(name, preface, vault)
104
+ <<~EOS
105
+ #{preface}
106
+ Note not found: #{name}
107
+ However, I found other notes linked to this note.
108
+ #{vault.links_by_file_name[name].shuffle.map { |file_path| "- #{File.basename(file_path, '.md')}" }.join("\n")}
109
+ EOS
110
+ end
111
+
112
+ def self.open_file(name, file_pathes, preface, vault)
113
+ # 複数のファイルがある場合は、---とファイル名で区切って返す
114
+ result = file_pathes.map do |file_path|
115
+ content = open(file_path) { |f| f.read.force_encoding('UTF-8') }
116
+ link_notes = if vault.links_by_file_path[file_path].nil?
117
+ ""
118
+ else
119
+ <<~EOS
120
+ This note is linked by the following notes:
121
+ #{(vault.links_by_file_path[file_path] || []).shuffle.map { |file_path| "- #{File.basename(file_path, '.md')}" }.join("\n")}
122
+ EOS
123
+ end
124
+ metadata = <<~EOS
125
+ The contents of the note '#{name}' is as follows.
126
+ #{link_notes}
127
+ ---
128
+ EOS
129
+ metadata + content
130
+ end.join("\n\n---\n\n")
131
+ preface + result
132
+ end
133
+ 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.2.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.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - sou7
@@ -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