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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +208 -0
  3. data/CONTRIBUTING.md +63 -1
  4. data/README.md +35 -2
  5. data/lib/tasks/woods.rake +52 -2
  6. data/lib/woods/atomic_file.rb +43 -0
  7. data/lib/woods/cache/cache_middleware.rb +18 -1
  8. data/lib/woods/console/embedded_executor.rb +20 -2
  9. data/lib/woods/console/eval_guard.rb +8 -2
  10. data/lib/woods/console/sql_noise_stripper.rb +113 -0
  11. data/lib/woods/console/sql_table_scanner.rb +22 -9
  12. data/lib/woods/console/sql_validator.rb +1 -2
  13. data/lib/woods/coordination/pipeline_lock.rb +112 -7
  14. data/lib/woods/dependency_graph.rb +38 -1
  15. data/lib/woods/embedding/text_preparer.rb +6 -1
  16. data/lib/woods/export/unit_facts.rb +80 -0
  17. data/lib/woods/extractor.rb +152 -31
  18. data/lib/woods/extractors/concern_extractor.rb +3 -5
  19. data/lib/woods/extractors/configuration_extractor.rb +3 -5
  20. data/lib/woods/extractors/controller_extractor.rb +1 -1
  21. data/lib/woods/extractors/database_view_extractor.rb +1 -1
  22. data/lib/woods/extractors/decorator_extractor.rb +3 -5
  23. data/lib/woods/extractors/event_extractor.rb +2 -2
  24. data/lib/woods/extractors/factory_extractor.rb +2 -4
  25. data/lib/woods/extractors/graphql_extractor.rb +1 -1
  26. data/lib/woods/extractors/i18n_extractor.rb +6 -4
  27. data/lib/woods/extractors/job_extractor.rb +4 -6
  28. data/lib/woods/extractors/lib_extractor.rb +1 -1
  29. data/lib/woods/extractors/mailer_extractor.rb +1 -1
  30. data/lib/woods/extractors/manager_extractor.rb +3 -5
  31. data/lib/woods/extractors/migration_extractor.rb +1 -1
  32. data/lib/woods/extractors/model_extractor.rb +26 -10
  33. data/lib/woods/extractors/phlex_extractor.rb +1 -1
  34. data/lib/woods/extractors/policy_extractor.rb +3 -5
  35. data/lib/woods/extractors/poro_extractor.rb +1 -1
  36. data/lib/woods/extractors/pundit_extractor.rb +3 -5
  37. data/lib/woods/extractors/rake_task_extractor.rb +2 -4
  38. data/lib/woods/extractors/serializer_extractor.rb +4 -6
  39. data/lib/woods/extractors/service_extractor.rb +3 -5
  40. data/lib/woods/extractors/shared_dependency_scanner.rb +88 -16
  41. data/lib/woods/extractors/shared_utility_methods.rb +14 -0
  42. data/lib/woods/extractors/state_machine_extractor.rb +2 -4
  43. data/lib/woods/extractors/validator_extractor.rb +3 -5
  44. data/lib/woods/extractors/view_component_extractor.rb +1 -1
  45. data/lib/woods/filename_utils.rb +31 -2
  46. data/lib/woods/flow_assembler.rb +45 -28
  47. data/lib/woods/flow_precomputer.rb +2 -1
  48. data/lib/woods/git_provenance.rb +121 -0
  49. data/lib/woods/index_artifact.rb +8 -4
  50. data/lib/woods/mcp/bootstrapper.rb +6 -5
  51. data/lib/woods/mcp/config_resolver.rb +42 -21
  52. data/lib/woods/mcp/errors.rb +5 -3
  53. data/lib/woods/mcp/server.rb +68 -15
  54. data/lib/woods/mcp/version_aware_tool_dispatch.rb +47 -0
  55. data/lib/woods/model_name_cache.rb +6 -1
  56. data/lib/woods/notion/client.rb +4 -1
  57. data/lib/woods/notion/exporter.rb +7 -1
  58. data/lib/woods/obsidian/errors.rb +11 -0
  59. data/lib/woods/obsidian/name_mapper.rb +132 -0
  60. data/lib/woods/obsidian/note_builder.rb +260 -0
  61. data/lib/woods/obsidian/vault_assets.rb +104 -0
  62. data/lib/woods/obsidian/vault_exporter.rb +412 -0
  63. data/lib/woods/operator/error_escalator.rb +15 -4
  64. data/lib/woods/resilience/circuit_breaker.rb +98 -24
  65. data/lib/woods/resolved_config.rb +4 -1
  66. data/lib/woods/retrieval/ranker.rb +45 -7
  67. data/lib/woods/retry_after.rb +36 -0
  68. data/lib/woods/temporal/json_snapshot_store.rb +4 -1
  69. data/lib/woods/temporal/snapshot_store.rb +4 -1
  70. data/lib/woods/unblocked/client.rb +4 -1
  71. data/lib/woods/unblocked/document_builder.rb +21 -27
  72. data/lib/woods/update_check.rb +190 -0
  73. data/lib/woods/version.rb +1 -1
  74. data/lib/woods.rb +24 -0
  75. 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 (deprecated, opt-in via
27
- # +WOODS_ALLOW_AUTODETECT=1+). Mutates +config+ to set provider and stores.
28
- # 4. If none of the above applies, raises {MissingArtifact}.
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
- # - If +WOODS_ALLOW_AUTODETECT+ is +1+, falls through to env-var
48
- # auto-detect (deprecated, emits a structured warning).
49
- # - Otherwise raises {MissingArtifact}.
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 +WOODS_ALLOW_AUTODETECT+ is unset.
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
- # Raises {MissingArtifact} unless +WOODS_ALLOW_AUTODETECT=1+ opts in to
205
- # the deprecated env-var auto-detect path.
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['WOODS_ALLOW_AUTODETECT'] != '1'
228
+ if env['WOODS_REQUIRE_INDEX'] == '1'
215
229
  raise MissingArtifact.new(
216
- 'No woods.json found and WOODS_ALLOW_AUTODETECT is unset. ' \
217
- 'Run `bundle exec rake woods:extract` in your host app, or set ' \
218
- 'WOODS_ALLOW_AUTODETECT=1 to probe env vars (deprecated).',
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
- warn '[woods-mcp] deprecated_autodetect: falling back to env-var auto-detect (no woods.json found)'
224
- [autodetect_from_env(config, env: env, ollama_probe: ollama_probe), :autodetect]
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
- # Only reachable when +WOODS_ALLOW_AUTODETECT=1+ and no +woods.json+
232
- # is present. Mutates +config+ to set provider and stores when a
233
- # credential or reachable Ollama instance is found.
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]
@@ -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 env flag
76
- # +WOODS_ALLOW_AUTODETECT+ is not set. Hosts that have never run an embed
77
- # see a clear failure message rather than silent degradation.
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(
@@ -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 = ENV['NOTION_API_TOKEN'] || (config.respond_to?(:notion_api_token) ? config.notion_api_token : nil)
179
+ token = Woods.resolve_notion_token(config)
162
180
  ids = config.respond_to?(:notion_database_ids) ? config.notion_database_ids : nil
163
- token && !token.empty? && ids && !ids.empty?
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
- filename = "#{controller.gsub('::', '__')}_#{action}.json"
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([]) : extractor.extract_all
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 ||= Mutex.new
991
- @pipeline_in_flight ||= {}
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&.synchronize { @pipeline_in_flight&.delete(kind) }
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
- # Set the class name in the error string for pattern matching
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
- unless config.notion_api_token
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
- /\b(?:#{names.map { |n| Regexp.escape(n) }.join('|')})\b/
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
@@ -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
- wait_time = (response['Retry-After'] || retries).to_f
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
- api_token = config.notion_api_token
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