woods 1.5.0 → 1.6.1
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 +4 -4
- data/CHANGELOG.md +171 -0
- data/CONTRIBUTING.md +30 -1
- data/README.md +31 -0
- data/lib/tasks/woods.rake +52 -2
- data/lib/woods/atomic_file.rb +43 -0
- data/lib/woods/cache/cache_middleware.rb +18 -1
- data/lib/woods/console/embedded_executor.rb +20 -2
- data/lib/woods/console/eval_guard.rb +8 -2
- data/lib/woods/console/sql_noise_stripper.rb +113 -0
- data/lib/woods/console/sql_table_scanner.rb +22 -9
- data/lib/woods/console/sql_validator.rb +1 -2
- data/lib/woods/coordination/pipeline_lock.rb +112 -7
- data/lib/woods/dependency_graph.rb +38 -1
- data/lib/woods/embedding/text_preparer.rb +6 -1
- data/lib/woods/export/unit_facts.rb +80 -0
- data/lib/woods/extractor.rb +153 -32
- data/lib/woods/extractors/concern_extractor.rb +3 -5
- data/lib/woods/extractors/configuration_extractor.rb +3 -5
- data/lib/woods/extractors/controller_extractor.rb +1 -1
- data/lib/woods/extractors/database_view_extractor.rb +1 -1
- data/lib/woods/extractors/decorator_extractor.rb +3 -5
- data/lib/woods/extractors/event_extractor.rb +2 -2
- data/lib/woods/extractors/factory_extractor.rb +2 -4
- data/lib/woods/extractors/graphql_extractor.rb +1 -1
- data/lib/woods/extractors/i18n_extractor.rb +6 -4
- data/lib/woods/extractors/job_extractor.rb +4 -6
- data/lib/woods/extractors/lib_extractor.rb +1 -1
- data/lib/woods/extractors/mailer_extractor.rb +1 -1
- data/lib/woods/extractors/manager_extractor.rb +3 -5
- data/lib/woods/extractors/migration_extractor.rb +1 -1
- data/lib/woods/extractors/model_extractor.rb +26 -10
- data/lib/woods/extractors/phlex_extractor.rb +1 -1
- data/lib/woods/extractors/policy_extractor.rb +3 -5
- data/lib/woods/extractors/poro_extractor.rb +1 -1
- data/lib/woods/extractors/pundit_extractor.rb +3 -5
- data/lib/woods/extractors/rake_task_extractor.rb +2 -4
- data/lib/woods/extractors/serializer_extractor.rb +4 -6
- data/lib/woods/extractors/service_extractor.rb +3 -5
- data/lib/woods/extractors/shared_dependency_scanner.rb +88 -16
- data/lib/woods/extractors/shared_utility_methods.rb +14 -0
- data/lib/woods/extractors/state_machine_extractor.rb +2 -4
- data/lib/woods/extractors/validator_extractor.rb +3 -5
- data/lib/woods/extractors/view_component_extractor.rb +1 -1
- data/lib/woods/filename_utils.rb +31 -2
- data/lib/woods/flow_assembler.rb +45 -28
- data/lib/woods/flow_precomputer.rb +2 -1
- data/lib/woods/index_artifact.rb +5 -1
- data/lib/woods/mcp/server.rb +68 -15
- data/lib/woods/mcp/version_aware_tool_dispatch.rb +47 -0
- data/lib/woods/model_name_cache.rb +6 -1
- data/lib/woods/notion/client.rb +4 -1
- data/lib/woods/notion/exporter.rb +7 -1
- data/lib/woods/obsidian/errors.rb +11 -0
- data/lib/woods/obsidian/name_mapper.rb +132 -0
- data/lib/woods/obsidian/note_builder.rb +260 -0
- data/lib/woods/obsidian/vault_assets.rb +104 -0
- data/lib/woods/obsidian/vault_exporter.rb +412 -0
- data/lib/woods/operator/error_escalator.rb +15 -4
- data/lib/woods/resilience/circuit_breaker.rb +98 -24
- data/lib/woods/resolved_config.rb +4 -1
- data/lib/woods/retrieval/ranker.rb +45 -7
- data/lib/woods/retry_after.rb +36 -0
- data/lib/woods/unblocked/client.rb +4 -1
- data/lib/woods/unblocked/document_builder.rb +21 -27
- data/lib/woods/update_check.rb +190 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods.rb +24 -0
- metadata +12 -2
data/lib/woods/flow_assembler.rb
CHANGED
|
@@ -60,19 +60,26 @@ module Woods
|
|
|
60
60
|
|
|
61
61
|
# Recursively expand a unit into flow steps.
|
|
62
62
|
#
|
|
63
|
+
# A cycle is a re-encounter on the CURRENT recursion path (tracked in
|
|
64
|
+
# +path+, popped on exit — the gray set of a DFS). A unit already
|
|
65
|
+
# expanded via a sibling branch (a DAG diamond: two services calling
|
|
66
|
+
# the same model) is plain dedup, not a cycle — it is skipped silently
|
|
67
|
+
# rather than mislabeled with a cycle marker.
|
|
68
|
+
#
|
|
63
69
|
# @param identifier [String] Unit identifier (may include #method)
|
|
64
70
|
# @param steps [Array<Hash>] Accumulator for step hashes
|
|
65
|
-
# @param visited [Set<String>]
|
|
71
|
+
# @param visited [Set<String>] Already-expanded unit identifiers (dedup)
|
|
66
72
|
# @param depth [Integer] Current recursion depth
|
|
67
73
|
# @param max_depth [Integer] Maximum recursion depth
|
|
68
|
-
|
|
74
|
+
# @param path [Set<String>] Identifiers on the current recursion path
|
|
75
|
+
def expand(identifier, steps, visited, depth:, max_depth:, path: Set.new)
|
|
69
76
|
return if depth > max_depth
|
|
70
77
|
|
|
71
78
|
# Parse identifier into unit name and optional method
|
|
72
79
|
unit_id, method_name = parse_identifier(identifier)
|
|
73
80
|
|
|
74
|
-
if
|
|
75
|
-
#
|
|
81
|
+
if path.include?(unit_id)
|
|
82
|
+
# Genuine cycle — the unit is an ancestor of itself on this path.
|
|
76
83
|
steps << {
|
|
77
84
|
unit: unit_id,
|
|
78
85
|
type: 'cycle',
|
|
@@ -81,34 +88,44 @@ module Woods
|
|
|
81
88
|
return
|
|
82
89
|
end
|
|
83
90
|
|
|
91
|
+
# Already expanded through another branch — dedup, not a cycle.
|
|
92
|
+
return if visited.include?(unit_id)
|
|
93
|
+
|
|
84
94
|
visited.add(unit_id)
|
|
95
|
+
path.add(unit_id)
|
|
85
96
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
97
|
+
begin
|
|
98
|
+
# Load the unit data from disk
|
|
99
|
+
unit_data = load_unit(unit_id)
|
|
100
|
+
return unless unit_data
|
|
89
101
|
|
|
90
|
-
|
|
91
|
-
|
|
102
|
+
source_code = unit_data[:source_code]
|
|
103
|
+
return unless source_code && !source_code.empty?
|
|
92
104
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
105
|
+
metadata = unit_data[:metadata] || {}
|
|
106
|
+
unit_type = unit_data[:type]&.to_s
|
|
107
|
+
file_path = unit_data[:file_path]
|
|
96
108
|
|
|
97
|
-
|
|
98
|
-
|
|
109
|
+
# Extract operations from the relevant method
|
|
110
|
+
operations = extract_operations(source_code, method_name, metadata, unit_type)
|
|
99
111
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
112
|
+
step = {
|
|
113
|
+
unit: identifier,
|
|
114
|
+
type: unit_type,
|
|
115
|
+
file_path: file_path,
|
|
116
|
+
operations: operations
|
|
117
|
+
}
|
|
106
118
|
|
|
107
|
-
|
|
119
|
+
steps << step
|
|
108
120
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
121
|
+
# Recursively expand targets that resolve to known units
|
|
122
|
+
operations.each do |op|
|
|
123
|
+
expand_operation(op, identifier, steps, visited, depth: depth, max_depth: max_depth, path: path)
|
|
124
|
+
end
|
|
125
|
+
ensure
|
|
126
|
+
# Pop on every exit (including the early returns above) — a unit
|
|
127
|
+
# left on the path would make sibling branches report false cycles.
|
|
128
|
+
path.delete(unit_id)
|
|
112
129
|
end
|
|
113
130
|
end
|
|
114
131
|
|
|
@@ -178,7 +195,7 @@ module Woods
|
|
|
178
195
|
# @param visited [Set<String>] Visited unit identifiers for cycle detection
|
|
179
196
|
# @param depth [Integer] Current recursion depth
|
|
180
197
|
# @param max_depth [Integer] Maximum recursion depth
|
|
181
|
-
def expand_operation(op, current_unit, steps, visited, depth:, max_depth:)
|
|
198
|
+
def expand_operation(op, current_unit, steps, visited, depth:, max_depth:, path: Set.new)
|
|
182
199
|
case op[:type]
|
|
183
200
|
when :call, :async
|
|
184
201
|
target = op[:target]
|
|
@@ -187,14 +204,14 @@ module Woods
|
|
|
187
204
|
candidate = resolve_target(target)
|
|
188
205
|
return unless candidate
|
|
189
206
|
|
|
190
|
-
expand(candidate, steps, visited, depth: depth + 1, max_depth: max_depth)
|
|
207
|
+
expand(candidate, steps, visited, depth: depth + 1, max_depth: max_depth, path: path)
|
|
191
208
|
when :transaction
|
|
192
209
|
(op[:nested] || []).each do |nested_op|
|
|
193
|
-
expand_operation(nested_op, current_unit, steps, visited, depth: depth, max_depth: max_depth)
|
|
210
|
+
expand_operation(nested_op, current_unit, steps, visited, depth: depth, max_depth: max_depth, path: path)
|
|
194
211
|
end
|
|
195
212
|
when :conditional
|
|
196
213
|
((op[:then_ops] || []) + (op[:else_ops] || [])).each do |branch_op|
|
|
197
|
-
expand_operation(branch_op, current_unit, steps, visited, depth: depth, max_depth: max_depth)
|
|
214
|
+
expand_operation(branch_op, current_unit, steps, visited, depth: depth, max_depth: max_depth, path: path)
|
|
198
215
|
end
|
|
199
216
|
end
|
|
200
217
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'fileutils'
|
|
5
|
+
require_relative 'filename_utils'
|
|
5
6
|
require_relative 'flow_assembler'
|
|
6
7
|
|
|
7
8
|
module Woods
|
|
@@ -80,7 +81,7 @@ module Woods
|
|
|
80
81
|
def assemble_and_write(assembler, entry_point, controller_id, action)
|
|
81
82
|
flow = assembler.assemble(entry_point, max_depth: @max_depth)
|
|
82
83
|
|
|
83
|
-
filename =
|
|
84
|
+
filename = Woods::FilenameUtils.flow_filename(controller_id, action)
|
|
84
85
|
flow_path = File.join(@flows_dir, filename)
|
|
85
86
|
|
|
86
87
|
File.write(flow_path, canonical_json(flow.to_h))
|
data/lib/woods/index_artifact.rb
CHANGED
|
@@ -127,7 +127,11 @@ module Woods
|
|
|
127
127
|
target_real = target.exist? ? target.realpath.to_s : ''
|
|
128
128
|
# Resolve symlinks on both sides before comparing (handles macOS /tmp → /private/var)
|
|
129
129
|
root_resolved = Pathname.new(root_real).exist? ? Pathname.new(root_real).realpath.to_s : root_real
|
|
130
|
-
|
|
130
|
+
# Prefix must end at a path boundary — a bare start_with? would accept
|
|
131
|
+
# sibling directories like "dumps-backup" or "dumpsevil", and the
|
|
132
|
+
# basename-only pointer written below would then name a directory that
|
|
133
|
+
# doesn't exist under dumps_root.
|
|
134
|
+
unless target.exist? && target_real.start_with?("#{root_resolved}#{File::SEPARATOR}")
|
|
131
135
|
raise ArgumentError,
|
|
132
136
|
'dump_dir must exist inside dumps_root. ' \
|
|
133
137
|
"Got: #{dump_dir.inspect}, dumps_root: #{dumps_root}"
|
data/lib/woods/mcp/server.rb
CHANGED
|
@@ -7,8 +7,11 @@ require 'open3'
|
|
|
7
7
|
require 'time'
|
|
8
8
|
require 'set'
|
|
9
9
|
require_relative '../tasks'
|
|
10
|
+
require_relative '../filename_utils'
|
|
11
|
+
require_relative '../update_check'
|
|
10
12
|
require_relative 'index_reader'
|
|
11
13
|
require_relative 'tool_response_renderer'
|
|
14
|
+
require_relative 'version_aware_tool_dispatch'
|
|
12
15
|
|
|
13
16
|
module Woods
|
|
14
17
|
module MCP
|
|
@@ -27,6 +30,15 @@ module Woods
|
|
|
27
30
|
# transport.open
|
|
28
31
|
#
|
|
29
32
|
module Server
|
|
33
|
+
# Module-level pipeline serialization state, eagerly initialized at load
|
|
34
|
+
# time (self == Server here). The HTTP transport dispatches tool handlers
|
|
35
|
+
# concurrently, so a lazy `@pipeline_mutex ||= Mutex.new` inside
|
|
36
|
+
# pipeline_start would let two handlers each create a DIFFERENT mutex and
|
|
37
|
+
# synchronize on separate locks — defeating the exclusion and allowing two
|
|
38
|
+
# concurrent pipelines of the same kind.
|
|
39
|
+
@pipeline_mutex = Mutex.new
|
|
40
|
+
@pipeline_in_flight = {}
|
|
41
|
+
|
|
30
42
|
class << self
|
|
31
43
|
# Build a configured MCP::Server with all tools and resources.
|
|
32
44
|
#
|
|
@@ -87,6 +99,9 @@ module Woods
|
|
|
87
99
|
resources: resources,
|
|
88
100
|
resource_templates: resource_templates
|
|
89
101
|
)
|
|
102
|
+
# Rewrite "Tool not found" into version-aware update guidance for agents
|
|
103
|
+
# running against an older gem than the skill they're following assumes.
|
|
104
|
+
server.singleton_class.prepend(VersionAwareToolDispatch)
|
|
90
105
|
|
|
91
106
|
define_lookup_tool(server, reader, respond, respond_err, renderer)
|
|
92
107
|
define_search_tool(server, reader, respond, respond_err, renderer)
|
|
@@ -152,15 +167,18 @@ module Woods
|
|
|
152
167
|
end
|
|
153
168
|
|
|
154
169
|
# Notion export needs both an API token and at least one database ID.
|
|
155
|
-
# NOTION_API_TOKEN env var overrides the config token (see
|
|
156
|
-
# docs/NOTION_EXPORT.md).
|
|
170
|
+
# A non-blank NOTION_API_TOKEN env var overrides the config token (see
|
|
171
|
+
# docs/NOTION_EXPORT.md). Resolution goes through
|
|
172
|
+
# Woods.resolve_notion_token so a blank env var is treated as absent
|
|
173
|
+
# (rather than masking a valid configured token) — matching the
|
|
174
|
+
# exporter and the notion_sync handler.
|
|
157
175
|
def notion_wired?
|
|
158
176
|
config = Woods.configuration
|
|
159
177
|
return false unless config
|
|
160
178
|
|
|
161
|
-
token =
|
|
179
|
+
token = Woods.resolve_notion_token(config)
|
|
162
180
|
ids = config.respond_to?(:notion_database_ids) ? config.notion_database_ids : nil
|
|
163
|
-
|
|
181
|
+
!token.nil? && ids && !ids.empty?
|
|
164
182
|
end
|
|
165
183
|
|
|
166
184
|
def text_response(text)
|
|
@@ -255,7 +273,11 @@ module Woods
|
|
|
255
273
|
controller, action = entry_point.split('#', 2)
|
|
256
274
|
return nil if controller.empty? || action.empty?
|
|
257
275
|
|
|
258
|
-
|
|
276
|
+
# entry_point is client input — FilenameUtils.flow_filename
|
|
277
|
+
# allow-lists both parts (so `/` and `..` can't traverse outside
|
|
278
|
+
# flows/) using the SAME transform FlowPrecomputer writes with, so a
|
|
279
|
+
# legitimately precomputed flow always resolves to the file on disk.
|
|
280
|
+
filename = Woods::FilenameUtils.flow_filename(controller, action)
|
|
259
281
|
path = File.join(index_dir, 'flows', filename)
|
|
260
282
|
return nil unless File.exist?(path)
|
|
261
283
|
|
|
@@ -875,12 +897,34 @@ module Woods
|
|
|
875
897
|
description: 'Trigger a codebase extraction pipeline run. Checks rate limits before proceeding.',
|
|
876
898
|
input_schema: {
|
|
877
899
|
properties: {
|
|
878
|
-
incremental: { type: 'boolean', description: 'Run incremental extraction (default: false)' }
|
|
900
|
+
incremental: { type: 'boolean', description: 'Run incremental extraction (default: false)' },
|
|
901
|
+
changed_files: {
|
|
902
|
+
type: 'array',
|
|
903
|
+
items: { type: 'string' },
|
|
904
|
+
description: 'Required when incremental is true: repo-relative paths of changed files ' \
|
|
905
|
+
'(the MCP server has no git context to compute them itself).'
|
|
906
|
+
}
|
|
879
907
|
}
|
|
880
908
|
}
|
|
881
|
-
) do |server_context:, incremental: nil|
|
|
909
|
+
) do |server_context:, incremental: nil, changed_files: nil|
|
|
882
910
|
next op_missing.call('pipeline_extract') unless operator
|
|
883
911
|
|
|
912
|
+
# Incremental extraction re-extracts only the units affected by
|
|
913
|
+
# the given files. The MCP server has no git-diff context (unlike
|
|
914
|
+
# the rake task, which derives them from CHANGED_FILES / CI env),
|
|
915
|
+
# so an empty list would re-extract nothing while still bumping
|
|
916
|
+
# the manifest timestamp — a silent no-op reporting success.
|
|
917
|
+
# Require the caller to supply the changed files explicitly.
|
|
918
|
+
files = Array(changed_files).map(&:to_s).reject(&:empty?)
|
|
919
|
+
if incremental && files.empty?
|
|
920
|
+
next respond_err.call(
|
|
921
|
+
'Incremental extraction requires a non-empty changed_files list. ' \
|
|
922
|
+
'Pass the changed paths, or run `rake woods:incremental` which derives them from git.',
|
|
923
|
+
code: :invalid_params,
|
|
924
|
+
tool: 'pipeline_extract'
|
|
925
|
+
)
|
|
926
|
+
end
|
|
927
|
+
|
|
884
928
|
guard = operator[:pipeline_guard]
|
|
885
929
|
if guard && !guard.allow?(:extraction)
|
|
886
930
|
next respond_err.call(
|
|
@@ -910,7 +954,7 @@ module Woods
|
|
|
910
954
|
extractor = Woods::Extractor.new(
|
|
911
955
|
output_dir: Woods.configuration.output_dir
|
|
912
956
|
)
|
|
913
|
-
incremental ? extractor.extract_changed(
|
|
957
|
+
incremental ? extractor.extract_changed(files) : extractor.extract_all
|
|
914
958
|
rescue StandardError => e
|
|
915
959
|
logger = defined?(Rails) ? Rails.logger : Logger.new($stderr)
|
|
916
960
|
logger.error("[Woods] Pipeline extract failed: #{e.message}")
|
|
@@ -987,8 +1031,9 @@ module Woods
|
|
|
987
1031
|
# pipeline). Module-level state — a single MCP server process
|
|
988
1032
|
# serializes its own pipelines.
|
|
989
1033
|
def pipeline_start(kind)
|
|
990
|
-
@pipeline_mutex
|
|
991
|
-
|
|
1034
|
+
# @pipeline_mutex / @pipeline_in_flight are initialized eagerly in
|
|
1035
|
+
# the module body — no lazy `||=` here, which would race under the
|
|
1036
|
+
# concurrent HTTP transport.
|
|
992
1037
|
@pipeline_mutex.synchronize do
|
|
993
1038
|
return false if @pipeline_in_flight[kind]
|
|
994
1039
|
|
|
@@ -998,7 +1043,7 @@ module Woods
|
|
|
998
1043
|
end
|
|
999
1044
|
|
|
1000
1045
|
def pipeline_finish(kind)
|
|
1001
|
-
@pipeline_mutex
|
|
1046
|
+
@pipeline_mutex.synchronize { @pipeline_in_flight.delete(kind) }
|
|
1002
1047
|
end
|
|
1003
1048
|
|
|
1004
1049
|
def define_pipeline_status_tool(server, operator, respond, respond_err, op_missing)
|
|
@@ -1048,9 +1093,11 @@ module Woods
|
|
|
1048
1093
|
)
|
|
1049
1094
|
end
|
|
1050
1095
|
|
|
1096
|
+
# Pass the client-supplied class name so class-keyed patterns
|
|
1097
|
+
# (Timeout, Net::, Errno::ENOENT, …) match — a bare
|
|
1098
|
+
# StandardError would classify everything as :unknown.
|
|
1051
1099
|
error = StandardError.new(error_message)
|
|
1052
|
-
|
|
1053
|
-
result = escalator.classify(error)
|
|
1100
|
+
result = escalator.classify(error, class_name: error_class)
|
|
1054
1101
|
result[:original_class] = error_class
|
|
1055
1102
|
respond.call(JSON.pretty_generate(result))
|
|
1056
1103
|
end
|
|
@@ -1302,7 +1349,12 @@ module Woods
|
|
|
1302
1349
|
}
|
|
1303
1350
|
) do |server_context:|
|
|
1304
1351
|
config = Woods.configuration
|
|
1305
|
-
|
|
1352
|
+
# Mirror notion_wired? (which gates registration) and the
|
|
1353
|
+
# Exporter via the shared resolver: a non-blank NOTION_API_TOKEN
|
|
1354
|
+
# env var satisfies the token requirement (reading config alone
|
|
1355
|
+
# rejected ENV-only hosts even though the tool was registered for
|
|
1356
|
+
# them), while a blank env var is treated as absent.
|
|
1357
|
+
if Woods.resolve_notion_token(config).nil?
|
|
1306
1358
|
next respond_err.call(
|
|
1307
1359
|
'notion_api_token is not configured. Set it in Woods.configure or via the NOTION_API_TOKEN env var.',
|
|
1308
1360
|
code: :not_configured,
|
|
@@ -1421,7 +1473,8 @@ module Woods
|
|
|
1421
1473
|
server: {
|
|
1422
1474
|
name: 'woods',
|
|
1423
1475
|
version: Woods::VERSION,
|
|
1424
|
-
index_dir: index_dir.to_s
|
|
1476
|
+
index_dir: index_dir.to_s,
|
|
1477
|
+
update: Woods::UpdateCheck.status_hash
|
|
1425
1478
|
},
|
|
1426
1479
|
index: index_section(manifest, extracted_at, staleness, index_dir),
|
|
1427
1480
|
retriever: {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../update_check'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module MCP
|
|
7
|
+
# Prepended onto a built +MCP::Server+ instance so that a call to a tool the
|
|
8
|
+
# installed gem does not define produces version-aware, self-healing
|
|
9
|
+
# guidance instead of a bare "Tool not found".
|
|
10
|
+
#
|
|
11
|
+
# An agent following a newer version of the distributed guide skills may try
|
|
12
|
+
# to call a tool that only exists in a later Woods release. The upstream
|
|
13
|
+
# +mcp+ gem raises +RequestHandlerError("Tool not found: X")+ with no seam to
|
|
14
|
+
# customize the message, so we intercept the built server's +call_tool+
|
|
15
|
+
# (dispatched directly by +handle_request+, so a prepend on the instance is
|
|
16
|
+
# honored on both the stdio and HTTP transports) and re-raise with the
|
|
17
|
+
# installed version plus an update hint the agent can relay to the user.
|
|
18
|
+
#
|
|
19
|
+
# Only the genuine tool-not-found case is rewritten; every other
|
|
20
|
+
# +RequestHandlerError+ (missing/invalid arguments, internal errors) is
|
|
21
|
+
# re-raised untouched.
|
|
22
|
+
module VersionAwareToolDispatch
|
|
23
|
+
# @param request [Hash] the tools/call params (carries +:name+)
|
|
24
|
+
# @return [MCP::Tool::Response] the tool result
|
|
25
|
+
# @raise [MCP::Server::RequestHandlerError] rewritten for unknown tools
|
|
26
|
+
def call_tool(request, **)
|
|
27
|
+
super
|
|
28
|
+
rescue ::MCP::Server::RequestHandlerError => e
|
|
29
|
+
raise unless tool_not_found_error?(e)
|
|
30
|
+
|
|
31
|
+
raise ::MCP::Server::RequestHandlerError.new(
|
|
32
|
+
Woods::UpdateCheck.tool_not_found_message(request[:name]),
|
|
33
|
+
request,
|
|
34
|
+
error_type: :invalid_params,
|
|
35
|
+
original_error: e
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# The gem raises exactly this shape for an unregistered tool name.
|
|
42
|
+
def tool_not_found_error?(error)
|
|
43
|
+
error.error_type == :invalid_params && error.message.to_s.start_with?('Tool not found:')
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -87,7 +87,12 @@ module Woods
|
|
|
87
87
|
names = model_names
|
|
88
88
|
return /(?!)/ if names.empty? # never-matching regex
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
# Longest-first: regex alternation is ordered, not longest-match.
|
|
91
|
+
# Without the sort, a reference to Library::Book::Chapter can match
|
|
92
|
+
# the shorter Library::Book alternative (\b is satisfied at the
|
|
93
|
+
# following ":"), emitting an edge to the wrong model.
|
|
94
|
+
sorted = names.sort_by { |n| -n.length }
|
|
95
|
+
/\b(?:#{sorted.map { |n| Regexp.escape(n) }.join('|')})\b/
|
|
91
96
|
end
|
|
92
97
|
|
|
93
98
|
# Build short-name → full-name mapping. A short name that appears on
|
data/lib/woods/notion/client.rb
CHANGED
|
@@ -4,6 +4,7 @@ require 'json'
|
|
|
4
4
|
require 'net/http'
|
|
5
5
|
require 'uri'
|
|
6
6
|
require 'woods'
|
|
7
|
+
require_relative '../retry_after'
|
|
7
8
|
require_relative 'rate_limiter'
|
|
8
9
|
|
|
9
10
|
module Woods
|
|
@@ -135,7 +136,9 @@ module Woods
|
|
|
135
136
|
|
|
136
137
|
if response.code == '429' && retries < MAX_RETRIES
|
|
137
138
|
retries += 1
|
|
138
|
-
|
|
139
|
+
# Retry-After may be an HTTP-date, which .to_f would collapse to
|
|
140
|
+
# 0.0 — honoring it as-is would hammer a throttling server.
|
|
141
|
+
wait_time = Woods::RetryAfter.seconds(response['Retry-After'], fallback: retries)
|
|
139
142
|
sleep(wait_time)
|
|
140
143
|
next
|
|
141
144
|
end
|
|
@@ -25,7 +25,13 @@ module Woods
|
|
|
25
25
|
# @param reader [Object, nil] IndexReader instance (auto-created from index_dir if nil)
|
|
26
26
|
# @raise [ConfigurationError] if notion_api_token is not configured
|
|
27
27
|
def initialize(index_dir:, config: Woods.configuration, client: nil, reader: nil)
|
|
28
|
-
|
|
28
|
+
# A non-blank NOTION_API_TOKEN overrides the configured token
|
|
29
|
+
# (documented contract; also what the MCP notion_wired? gate keys on).
|
|
30
|
+
# Resolve via the shared Woods.resolve_notion_token so the exporter,
|
|
31
|
+
# the MCP tool, and the rake task treat a blank env var identically —
|
|
32
|
+
# a set-but-empty NOTION_API_TOKEN must not mask a configured token or
|
|
33
|
+
# pass through as a blank bearer.
|
|
34
|
+
api_token = Woods.resolve_notion_token(config)
|
|
29
35
|
raise ConfigurationError, 'notion_api_token is required for Notion export' unless api_token
|
|
30
36
|
|
|
31
37
|
@database_ids = config.notion_database_ids || {}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'woods'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module Obsidian
|
|
7
|
+
# Raised for recoverable Obsidian export failures (e.g. a missing extraction
|
|
8
|
+
# output dir). Inherits Woods::Error so callers can rescue the gem's hierarchy.
|
|
9
|
+
class ExportError < Woods::Error; end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
require 'digest'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module Obsidian
|
|
8
|
+
# Centralizes the identifier -> note-filename mapping for an Obsidian vault.
|
|
9
|
+
#
|
|
10
|
+
# This is the single source of truth for three things that must always
|
|
11
|
+
# agree: the filename a note is written to, the wikilink target that points
|
|
12
|
+
# at it, and the inverse +path -> id+ map shipped in the machine sidecar.
|
|
13
|
+
# Building them in one place means a link can never point at a filename the
|
|
14
|
+
# writer didn't produce.
|
|
15
|
+
#
|
|
16
|
+
# Rules:
|
|
17
|
+
# - Sanitize +::+ to +__+ and every other non +[A-Za-z0-9_-]+ char to +_+,
|
|
18
|
+
# so the basename is always safe both as a filename and as a wikilink
|
|
19
|
+
# target (Obsidian's +# | ^ : %%+ link-breaking chars are all removed).
|
|
20
|
+
# - Collisions are resolved **per folder**, case-insensitively (macOS/Windows
|
|
21
|
+
# fold case): the colliding note gets a short content hash appended. Two
|
|
22
|
+
# identical basenames in *different* type folders are distinct paths and
|
|
23
|
+
# need no hash.
|
|
24
|
+
# - The MOC basename +_index+ is reserved in every folder so a (pathological)
|
|
25
|
+
# unit named +_index+ can never overwrite a folder's index note.
|
|
26
|
+
# - Filenames are capped to 255 bytes, truncated on a UTF-8 char boundary.
|
|
27
|
+
# - The alias (the human-readable display half of a wikilink) keeps the
|
|
28
|
+
# original identifier, with only the link-structural chars +[ ] |+ replaced.
|
|
29
|
+
#
|
|
30
|
+
# Determinism: ids are processed in sorted order, so the same input always
|
|
31
|
+
# produces the same collision-hash assignments.
|
|
32
|
+
class NameMapper
|
|
33
|
+
MAX_FILENAME_BYTES = 255
|
|
34
|
+
EXTENSION = '.md'
|
|
35
|
+
HASH_SUFFIX_BYTES = 9 # "-" + 8 hex chars
|
|
36
|
+
RESERVED_BASENAMES = %w[_index].freeze
|
|
37
|
+
|
|
38
|
+
# @param id_to_dir [Hash{String=>String}] map of unit identifier -> type
|
|
39
|
+
# folder name (e.g. "User" => "models")
|
|
40
|
+
def initialize(id_to_dir)
|
|
41
|
+
@map = {}
|
|
42
|
+
@paths = {}
|
|
43
|
+
build(id_to_dir)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Array<String>] exported identifiers, sorted
|
|
47
|
+
def ids
|
|
48
|
+
@map.keys
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @param id [String]
|
|
52
|
+
# @return [Boolean] whether this id has an emitted note
|
|
53
|
+
def known?(id)
|
|
54
|
+
@map.key?(id)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @param id [String]
|
|
58
|
+
# @return [String, nil] vault-relative note path ("models/User.md")
|
|
59
|
+
def path_for(id)
|
|
60
|
+
@map[id]&.fetch(:path)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param id [String]
|
|
64
|
+
# @return [String, nil] wikilink target with no extension ("models/User")
|
|
65
|
+
def target_for(id)
|
|
66
|
+
@map[id]&.fetch(:target)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @param id [String]
|
|
70
|
+
# @return [String, nil] full wikilink ("[[models/User|User]]") or nil if unknown
|
|
71
|
+
def wikilink(id)
|
|
72
|
+
entry = @map[id]
|
|
73
|
+
return nil unless entry
|
|
74
|
+
|
|
75
|
+
"[[#{entry[:target]}|#{entry[:alias]}]]"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Hash{String=>String}] inverse path -> id map (sorted)
|
|
79
|
+
def paths_to_ids
|
|
80
|
+
@paths
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def build(id_to_dir)
|
|
86
|
+
taken = Hash.new { |h, dir| h[dir] = Set.new(RESERVED_BASENAMES) }
|
|
87
|
+
id_to_dir.keys.sort.each do |id|
|
|
88
|
+
dir = id_to_dir[id]
|
|
89
|
+
basename = assign_basename(id, taken[dir])
|
|
90
|
+
path = "#{dir}/#{basename}#{EXTENSION}"
|
|
91
|
+
@map[id] = { dir: dir, basename: basename, path: path, target: "#{dir}/#{basename}", alias: alias_for(id) }
|
|
92
|
+
@paths[path] = id
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def assign_basename(id, used)
|
|
97
|
+
base = fit(sanitize(id), EXTENSION.bytesize)
|
|
98
|
+
if used.include?(base.downcase)
|
|
99
|
+
base = "#{fit(sanitize(id), EXTENSION.bytesize + HASH_SUFFIX_BYTES)}-#{Digest::SHA256.hexdigest(id)[0, 8]}"
|
|
100
|
+
end
|
|
101
|
+
used << base.downcase
|
|
102
|
+
base
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def sanitize(id)
|
|
106
|
+
out = id.to_s.gsub('::', '__').gsub(/[^a-zA-Z0-9_-]/, '_')
|
|
107
|
+
out.empty? ? 'unit' : out
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Replace only the chars that are structural inside a wikilink alias.
|
|
111
|
+
# `#` and `:` are safe in alias display text and are kept for readability.
|
|
112
|
+
def alias_for(id)
|
|
113
|
+
id.to_s.gsub('[', '(').gsub(']', ')').gsub('|', '/')
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Truncate +str+ so it fits within MAX_FILENAME_BYTES minus +reserve+
|
|
117
|
+
# bytes, never splitting a multibyte character.
|
|
118
|
+
def fit(str, reserve)
|
|
119
|
+
limit = MAX_FILENAME_BYTES - reserve
|
|
120
|
+
return str if str.bytesize <= limit
|
|
121
|
+
|
|
122
|
+
out = +''
|
|
123
|
+
str.each_char do |ch|
|
|
124
|
+
break if out.bytesize + ch.bytesize > limit
|
|
125
|
+
|
|
126
|
+
out << ch
|
|
127
|
+
end
|
|
128
|
+
out
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|