woods 1.4.1 → 1.6.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 +4 -4
- data/CHANGELOG.md +208 -0
- data/CONTRIBUTING.md +63 -1
- data/README.md +35 -2
- 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 +152 -31
- 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/git_provenance.rb +121 -0
- data/lib/woods/index_artifact.rb +8 -4
- data/lib/woods/mcp/bootstrapper.rb +6 -5
- data/lib/woods/mcp/config_resolver.rb +42 -21
- data/lib/woods/mcp/errors.rb +5 -3
- 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/temporal/json_snapshot_store.rb +4 -1
- data/lib/woods/temporal/snapshot_store.rb +4 -1
- 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 +15 -4
|
@@ -23,9 +23,12 @@ module Woods
|
|
|
23
23
|
# compatible via {ResolvedConfig#assert_compatible!}.
|
|
24
24
|
# 2. +woods.json+ snapshot alone (MCP server running without a host initializer).
|
|
25
25
|
# The stored config is used to populate +config+ in place.
|
|
26
|
-
# 3. Environment-variable auto-detect (
|
|
27
|
-
#
|
|
28
|
-
#
|
|
26
|
+
# 3. Environment-variable auto-detect (the default when +woods.json+ is
|
|
27
|
+
# absent and no host provider is set). Wires semantic search if
|
|
28
|
+
# +OPENAI_API_KEY+ or a reachable Ollama is found; otherwise leaves the
|
|
29
|
+
# provider nil so the server boots in pattern/structural-only mode.
|
|
30
|
+
# 4. +WOODS_REQUIRE_INDEX=1+ overrides (3) to fail closed, raising
|
|
31
|
+
# {MissingArtifact} when +woods.json+ is absent.
|
|
29
32
|
#
|
|
30
33
|
# @example Typical Bootstrapper usage
|
|
31
34
|
# config, source = ConfigResolver.resolve(Woods.configuration, artifact: artifact)
|
|
@@ -43,10 +46,11 @@ module Woods
|
|
|
43
46
|
# When +woods.json+ is absent and the host already has a provider
|
|
44
47
|
# configured, the host config is trusted and used as-is.
|
|
45
48
|
#
|
|
46
|
-
# When +woods.json+ is absent and the host has no provider configured
|
|
47
|
-
# -
|
|
48
|
-
#
|
|
49
|
-
#
|
|
49
|
+
# When +woods.json+ is absent and the host has no provider configured,
|
|
50
|
+
# auto-detect runs by default: pattern/structural tools always work, and
|
|
51
|
+
# semantic search wires up only if +OPENAI_API_KEY+ or a reachable Ollama
|
|
52
|
+
# is found. Set +WOODS_REQUIRE_INDEX=1+ to fail closed and raise
|
|
53
|
+
# {MissingArtifact} instead.
|
|
50
54
|
#
|
|
51
55
|
# @param config [Woods::Configuration] the live host configuration object.
|
|
52
56
|
# May be mutated in the auto-detect path.
|
|
@@ -61,7 +65,7 @@ module Woods
|
|
|
61
65
|
# where +source+ is one of +:snapshot+, +:host_config+, +:autodetect+,
|
|
62
66
|
# or +:none+. The config is the same object passed in, possibly mutated.
|
|
63
67
|
# @raise [Woods::MCP::MissingArtifact] when +woods.json+ is absent, the
|
|
64
|
-
# host has no provider configured, and +
|
|
68
|
+
# host has no provider configured, and +WOODS_REQUIRE_INDEX=1+ is set.
|
|
65
69
|
# @raise [Woods::MCP::UnsupportedArtifact] when +woods.json+ has an
|
|
66
70
|
# unsupported +schema_version+.
|
|
67
71
|
# @raise [Woods::MCP::DimensionMismatch] when stored and live provider
|
|
@@ -201,36 +205,53 @@ module Woods
|
|
|
201
205
|
|
|
202
206
|
# Handle the no-artifact, no-host-provider case.
|
|
203
207
|
#
|
|
204
|
-
#
|
|
205
|
-
#
|
|
208
|
+
# Extract-only hosts — those that ran +woods:extract+ but never
|
|
209
|
+
# +woods:embed+ and configured no embedding provider — boot in
|
|
210
|
+
# pattern/structural mode by default. The env-var auto-detect path runs,
|
|
211
|
+
# wiring semantic search only if +OPENAI_API_KEY+ or a reachable Ollama
|
|
212
|
+
# instance is found. With neither, +config+ comes back with a nil provider
|
|
213
|
+
# and {Bootstrapper.build_retriever} returns a nil retriever, leaving the
|
|
214
|
+
# always-on pattern/regex/structural tools fully functional.
|
|
215
|
+
#
|
|
216
|
+
# Set +WOODS_REQUIRE_INDEX=1+ to fail closed instead: demand a real
|
|
217
|
+
# +woods.json+ and raise {MissingArtifact} when it is absent.
|
|
218
|
+
# +WOODS_ALLOW_AUTODETECT+ is retained for backward compatibility but no
|
|
219
|
+
# longer changes behaviour — auto-detect is now the default.
|
|
206
220
|
#
|
|
207
221
|
# @param config [Woods::Configuration]
|
|
208
222
|
# @param artifact [Woods::IndexArtifact, nil]
|
|
209
223
|
# @param env [Hash]
|
|
210
224
|
# @param ollama_probe [#call, nil]
|
|
211
|
-
# @return [Woods::Configuration]
|
|
212
|
-
# @raise [Woods::MCP::MissingArtifact]
|
|
225
|
+
# @return [Array(Woods::Configuration, Symbol)]
|
|
226
|
+
# @raise [Woods::MCP::MissingArtifact] only when +WOODS_REQUIRE_INDEX=1+.
|
|
213
227
|
def self.resolve_without_artifact(config, artifact:, env:, ollama_probe:)
|
|
214
|
-
if env['
|
|
228
|
+
if env['WOODS_REQUIRE_INDEX'] == '1'
|
|
215
229
|
raise MissingArtifact.new(
|
|
216
|
-
'No woods.json found and
|
|
217
|
-
'Run `bundle exec rake woods:extract`
|
|
218
|
-
'
|
|
230
|
+
'No woods.json found and WOODS_REQUIRE_INDEX=1 demands a real index. ' \
|
|
231
|
+
'Run `bundle exec rake woods:extract` (then `woods:embed` for semantic ' \
|
|
232
|
+
'search) in your host app, or unset WOODS_REQUIRE_INDEX to boot in ' \
|
|
233
|
+
'pattern-only mode.',
|
|
219
234
|
details: { output_dir: artifact&.output_dir&.to_s }
|
|
220
235
|
)
|
|
221
236
|
end
|
|
222
237
|
|
|
223
|
-
|
|
224
|
-
|
|
238
|
+
resolved = autodetect_from_env(config, env: env, ollama_probe: ollama_probe)
|
|
239
|
+
unless resolved.embedding_provider
|
|
240
|
+
warn '[woods-mcp] no woods.json and no embedding provider — serving ' \
|
|
241
|
+
'pattern/structural tools only. Run `rake woods:embed`, or set ' \
|
|
242
|
+
'OPENAI_API_KEY / run Ollama, to enable semantic search.'
|
|
243
|
+
end
|
|
244
|
+
[resolved, :autodetect]
|
|
225
245
|
end
|
|
226
246
|
private_class_method :resolve_without_artifact
|
|
227
247
|
|
|
228
248
|
# Probe environment variables for provider credentials and configure
|
|
229
249
|
# +config+ accordingly.
|
|
230
250
|
#
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
251
|
+
# Reached by default when no +woods.json+ is present and the host
|
|
252
|
+
# configured no provider (unless +WOODS_REQUIRE_INDEX=1+). Mutates
|
|
253
|
+
# +config+ to set provider and stores when a credential or reachable
|
|
254
|
+
# Ollama instance is found; leaves the provider nil otherwise.
|
|
234
255
|
#
|
|
235
256
|
# @param config [Woods::Configuration]
|
|
236
257
|
# @param env [Hash]
|
data/lib/woods/mcp/errors.rb
CHANGED
|
@@ -72,9 +72,11 @@ module Woods
|
|
|
72
72
|
# )
|
|
73
73
|
class UnsupportedArtifact < BootstrapError; end
|
|
74
74
|
|
|
75
|
-
# Raised when +woods.json+ is absent from +output_dir+ and the
|
|
76
|
-
#
|
|
77
|
-
#
|
|
75
|
+
# Raised when +woods.json+ is absent from +output_dir+ and the operator has
|
|
76
|
+
# opted into strict mode with +WOODS_REQUIRE_INDEX=1+. By default an absent
|
|
77
|
+
# artifact is *not* an error — the server boots in pattern/structural-only
|
|
78
|
+
# mode (see {ConfigResolver.resolve_without_artifact}). Strict mode exists
|
|
79
|
+
# for deployments that want to fail closed when an index is missing.
|
|
78
80
|
#
|
|
79
81
|
# @example
|
|
80
82
|
# raise Woods::MCP::MissingArtifact.new(
|
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
|