woods 2.0.0 → 2.1.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 +108 -0
- data/CONTRIBUTING.md +135 -10
- data/README.md +1 -1
- data/docs/AGENT_GUIDE.md +19 -0
- data/docs/AGENT_SETUP.md +22 -2
- data/docs/BACKEND_MATRIX.md +7 -0
- data/docs/CLIENT_HOOKS.md +6 -0
- data/docs/CONFIGURATION_REFERENCE.md +133 -18
- data/docs/CONSOLE_MCP_SETUP.md +141 -12
- data/docs/EMBEDDING_MODELS.md +16 -19
- data/docs/EXTRACTOR_REFERENCE.md +219 -21
- data/docs/FAQ.md +11 -25
- data/docs/GETTING_STARTED.md +7 -1
- data/docs/INCREMENTAL_EXTRACTION.md +261 -19
- data/docs/INDEX_LAYOUT.md +5 -0
- data/docs/INTERNALS.md +9 -0
- data/docs/MCP_HTTP_TRANSPORT.md +59 -2
- data/docs/MCP_SERVERS.md +87 -8
- data/docs/MCP_TOOL_COOKBOOK.md +13 -55
- data/docs/NOTION_INTEGRATION.md +7 -1
- data/docs/PUBLISHED_INDEX.md +6 -0
- data/docs/README.md +6 -1
- data/docs/RETRIEVAL_GUIDE.md +17 -0
- data/docs/SOURCE_FRESHNESS.md +157 -5
- data/docs/TOKEN_BENCHMARK.md +10 -18
- data/docs/TROUBLESHOOTING.md +70 -14
- data/docs/UNBLOCKED_INTEGRATION.md +60 -8
- data/docs/UPGRADING_TO_2.md +184 -9
- data/docs/WATCH_DAEMON.md +97 -14
- data/exe/woods-console-mcp +2 -2
- data/exe/woods-mcp-http +16 -9
- data/lib/generators/woods/templates/woods.rb.tt +2 -1
- data/lib/tasks/woods.rake +23 -7
- data/lib/tasks/woods_checks.rake +2 -2
- data/lib/woods/agent_configuration/cli.rb +1 -1
- data/lib/woods/agent_configuration/layout.rb +16 -2
- data/lib/woods/agent_configuration/plan.rb +13 -3
- data/lib/woods/agent_configuration/planner_validation.rb +4 -2
- data/lib/woods/agent_configuration/preflight.rb +5 -3
- data/lib/woods/builder.rb +17 -57
- data/lib/woods/cache/cache_middleware.rb +68 -41
- data/lib/woods/chunking/contributor_chunks.rb +119 -0
- data/lib/woods/chunking/semantic_chunker.rb +44 -21
- data/lib/woods/console/adapter_family.rb +39 -0
- data/lib/woods/console/connection_manager.rb +56 -3
- data/lib/woods/console/credential_index.rb +33 -3
- data/lib/woods/console/embedded_executor.rb +431 -48
- data/lib/woods/console/model_validator.rb +8 -0
- data/lib/woods/console/rack_middleware.rb +68 -11
- data/lib/woods/console/redactor.rb +24 -10
- data/lib/woods/console/safe_context.rb +44 -7
- data/lib/woods/console/sql_noise_stripper.rb +41 -12
- data/lib/woods/console/sql_table_scanner.rb +45 -34
- data/lib/woods/console/sql_validator.rb +37 -2
- data/lib/woods/dependency_graph.rb +34 -10
- data/lib/woods/embedding/fake.rb +12 -0
- data/lib/woods/embedding/indexer.rb +195 -98
- data/lib/woods/embedding/input_budget.rb +67 -0
- data/lib/woods/embedding/openai.rb +70 -20
- data/lib/woods/embedding/provider.rb +37 -25
- data/lib/woods/embedding/text_preparer.rb +76 -32
- data/lib/woods/embedding/token_counter.rb +18 -81
- data/lib/woods/embedding/vector_configuration.rb +48 -0
- data/lib/woods/extraction_identities.rb +175 -0
- data/lib/woods/extractor.rb +304 -107
- data/lib/woods/extractors/action_cable_extractor.rb +8 -3
- data/lib/woods/extractors/assigned_value_discovery.rb +74 -0
- data/lib/woods/extractors/class_declarations.rb +121 -0
- data/lib/woods/extractors/configuration_extractor.rb +11 -3
- data/lib/woods/extractors/declaration_ancestry.rb +92 -0
- data/lib/woods/extractors/event_extractor.rb +8 -0
- data/lib/woods/extractors/graphql_extractor.rb +134 -77
- data/lib/woods/extractors/job_extractor.rb +5 -1
- data/lib/woods/extractors/lib_extractor.rb +132 -15
- data/lib/woods/extractors/mailer_extractor.rb +3 -5
- data/lib/woods/extractors/manager_extractor.rb +7 -21
- data/lib/woods/extractors/migration_declaration.rb +87 -0
- data/lib/woods/extractors/migration_extractor.rb +5 -39
- data/lib/woods/extractors/phlex_extractor.rb +6 -2
- data/lib/woods/extractors/policy_extractor.rb +9 -5
- data/lib/woods/extractors/poro_extractor.rb +112 -53
- data/lib/woods/extractors/pundit_extractor.rb +11 -6
- data/lib/woods/extractors/scheduled_job_extractor.rb +45 -4
- data/lib/woods/extractors/serializer_extractor.rb +34 -22
- data/lib/woods/extractors/shared_utility_methods.rb +18 -1
- data/lib/woods/extractors/source_nesting.rb +142 -106
- data/lib/woods/extractors/standalone_module_discovery.rb +123 -0
- data/lib/woods/extractors/state_machine_extractor.rb +46 -40
- data/lib/woods/extractors/view_component_extractor.rb +9 -7
- data/lib/woods/flow_assembler.rb +4 -1
- data/lib/woods/generation.rb +25 -0
- data/lib/woods/hooks/context_hint.rb +7 -2
- data/lib/woods/mcp/bearer_auth.rb +1 -1
- data/lib/woods/mcp/bootstrapper.rb +33 -7
- data/lib/woods/mcp/config_resolver.rb +26 -7
- data/lib/woods/mcp/index_reader.rb +125 -24
- data/lib/woods/mcp/index_reader_pinning.rb +16 -0
- data/lib/woods/mcp/origin_guard.rb +24 -77
- data/lib/woods/mcp/origin_policy.rb +124 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +7 -1
- data/lib/woods/mcp/renderers/plain_renderer.rb +3 -1
- data/lib/woods/mcp/search_results.rb +7 -1
- data/lib/woods/mcp/server.rb +24 -4
- data/lib/woods/module_reconciliation.rb +151 -0
- data/lib/woods/path_dispatcher.rb +7 -2
- data/lib/woods/railtie_support.rb +8 -0
- data/lib/woods/rake_helpers.rb +43 -11
- data/lib/woods/release.rb +1 -1
- data/lib/woods/resilience/index_validator.rb +8 -3
- data/lib/woods/resilience/retryable_provider.rb +18 -1
- data/lib/woods/resolved_config.rb +68 -8
- data/lib/woods/retrieval/context_assembler.rb +3 -3
- data/lib/woods/retrieval/lexical_assembler.rb +3 -2
- data/lib/woods/retrieval/scope.rb +18 -2
- data/lib/woods/retrieval/source_evidence.rb +14 -2
- data/lib/woods/source_contributor_validation.rb +78 -0
- data/lib/woods/source_contributors.rb +116 -0
- data/lib/woods/source_inputs/handoff.rb +37 -0
- data/lib/woods/source_inputs/launcher.rb +53 -13
- data/lib/woods/source_inputs/manifest.rb +84 -3
- data/lib/woods/source_inputs/private_key.rb +44 -12
- data/lib/woods/source_inputs/scanner.rb +98 -27
- data/lib/woods/source_inputs/scopes.rb +1 -1
- data/lib/woods/source_inputs/session.rb +147 -15
- data/lib/woods/source_inputs/stable_reader.rb +127 -0
- data/lib/woods/source_inputs/status.rb +40 -8
- data/lib/woods/source_inputs/verifier.rb +28 -5
- data/lib/woods/source_path_encoding.rb +33 -0
- data/lib/woods/source_references/cache.rb +284 -0
- data/lib/woods/source_references/collector.rb +120 -0
- data/lib/woods/source_references/extraction.rb +185 -0
- data/lib/woods/source_references/inputs.rb +134 -0
- data/lib/woods/source_references/parser_adapter.rb +134 -0
- data/lib/woods/source_references/pass.rb +152 -0
- data/lib/woods/source_references/prism_adapter.rb +116 -0
- data/lib/woods/source_references/registry.rb +178 -0
- data/lib/woods/source_references/runtime_lookup.rb +127 -0
- data/lib/woods/source_references/value_class.rb +82 -0
- data/lib/woods/storage/metadata_store.rb +4 -1
- data/lib/woods/storage/qdrant.rb +2 -2
- data/lib/woods/unblocked/client.rb +12 -7
- data/lib/woods/unblocked/document_builder.rb +4 -1
- data/lib/woods/unblocked/exporter.rb +127 -37
- data/lib/woods/unblocked/sync_manifest.rb +137 -21
- data/lib/woods/unblocked/uri_migration.rb +105 -0
- data/lib/woods/util/host_guard.rb +3 -2
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/catch_up.rb +138 -0
- data/lib/woods/watch/claim_lease.rb +150 -0
- data/lib/woods/watch/cli.rb +26 -2
- data/lib/woods/watch/daemon.rb +80 -59
- data/lib/woods/watch/installation/options.rb +1 -1
- data/lib/woods/watch/installation/receipt.rb +6 -1
- data/lib/woods/watch/managed_child.rb +1 -1
- data/lib/woods/watch/supervisor.rb +1 -1
- data/lib/woods/watch/tree_scan.rb +14 -2
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/hooks/adapters/normalize.rb +3 -2
- data/plugin/hooks/woods-input-rules.sh +4 -0
- data/plugin/hooks/woods-refresh.sh +15 -7
- data/plugin/hooks/woods-session-start.sh +60 -3
- data/plugin/skills/woods-diagnose/SKILL.md +336 -2
- data/plugin/skills/woods-investigate/SKILL.md +11 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +86 -0
- data/plugin/skills/woods-setup/SKILL.md +56 -1
- metadata +34 -5
data/lib/woods/mcp/server.rb
CHANGED
|
@@ -601,6 +601,8 @@ module Woods
|
|
|
601
601
|
respond.call(renderer.render(:search, payload))
|
|
602
602
|
rescue Retrieval::Scope::InvalidScopeError => e
|
|
603
603
|
respond_err.call(e.message, code: :unsupported_argument, tool: 'search', argument: 'scope')
|
|
604
|
+
rescue ArgumentError => e
|
|
605
|
+
respond_err.call(e.message, code: :invalid_params, tool: 'search', argument: 'types')
|
|
604
606
|
rescue IOError, SystemCallError, JSON::ParserError, EncodingError
|
|
605
607
|
respond_err.call(
|
|
606
608
|
'Search completeness: unknown (unreadable_or_corrupt_source). ' \
|
|
@@ -1169,6 +1171,10 @@ module Woods
|
|
|
1169
1171
|
}
|
|
1170
1172
|
) do |entry_point:, server_context:, depth: nil|
|
|
1171
1173
|
max_depth = coerce_int.call(depth) || 3
|
|
1174
|
+
unless reader.find_unit(entry_point.split('#', 2).first)
|
|
1175
|
+
next respond_err.call('Flow entry unit not found; use search and lookup to select an indexed unit.',
|
|
1176
|
+
code: :not_found, tool: 'trace_flow', entry_point: entry_point)
|
|
1177
|
+
end
|
|
1172
1178
|
|
|
1173
1179
|
# Prefer the precomputed flow JSON written by FlowPrecomputer during
|
|
1174
1180
|
# extraction (gated on `config.precompute_flows`) — it avoids
|
|
@@ -1316,6 +1322,7 @@ module Woods
|
|
|
1316
1322
|
require_relative '../extractor' unless defined?(Woods::Extractor)
|
|
1317
1323
|
extractor = Woods::Extractor.new(output_dir: output_dir)
|
|
1318
1324
|
incremental ? extractor.extract_changed(files) : extractor.extract_all
|
|
1325
|
+
extractor.raise_on_publication_failure!
|
|
1319
1326
|
end
|
|
1320
1327
|
end
|
|
1321
1328
|
|
|
@@ -1893,7 +1900,7 @@ module Woods
|
|
|
1893
1900
|
|
|
1894
1901
|
def define_snapshot_tools(server, snapshot_store, respond, respond_err, snap_missing)
|
|
1895
1902
|
define_list_snapshots_tool(server, snapshot_store, respond, snap_missing)
|
|
1896
|
-
define_snapshot_diff_tool(server, snapshot_store, respond, snap_missing)
|
|
1903
|
+
define_snapshot_diff_tool(server, snapshot_store, respond, respond_err, snap_missing)
|
|
1897
1904
|
define_unit_history_tool(server, snapshot_store, respond, snap_missing)
|
|
1898
1905
|
define_snapshot_detail_tool(server, snapshot_store, respond, respond_err, snap_missing)
|
|
1899
1906
|
end
|
|
@@ -1918,7 +1925,7 @@ module Woods
|
|
|
1918
1925
|
end
|
|
1919
1926
|
end
|
|
1920
1927
|
|
|
1921
|
-
def define_snapshot_diff_tool(server, snapshot_store, respond, snap_missing)
|
|
1928
|
+
def define_snapshot_diff_tool(server, snapshot_store, respond, respond_err, snap_missing)
|
|
1922
1929
|
server.define_tool(
|
|
1923
1930
|
name: 'snapshot_diff',
|
|
1924
1931
|
description: 'Compare two extraction snapshots by git SHA. Returns lists of added, modified, and deleted units.',
|
|
@@ -1932,6 +1939,17 @@ module Woods
|
|
|
1932
1939
|
) do |sha_a:, sha_b:, server_context:|
|
|
1933
1940
|
next snap_missing.call('snapshot_diff') unless snapshot_store
|
|
1934
1941
|
|
|
1942
|
+
unless [sha_a, sha_b].all? { |sha| sha.match?(/\A[0-9a-f]+\z/i) }
|
|
1943
|
+
next respond_err.call('Snapshot SHAs must be hexadecimal identifiers from list_snapshots.',
|
|
1944
|
+
code: :invalid_params, tool: 'snapshot_diff')
|
|
1945
|
+
end
|
|
1946
|
+
sha_a = sha_a.downcase
|
|
1947
|
+
sha_b = sha_b.downcase
|
|
1948
|
+
unless snapshot_store.find(sha_a) && snapshot_store.find(sha_b)
|
|
1949
|
+
next respond_err.call('One or both snapshots were not found; use list_snapshots for available SHAs.',
|
|
1950
|
+
code: :not_found, tool: 'snapshot_diff')
|
|
1951
|
+
end
|
|
1952
|
+
|
|
1935
1953
|
result = snapshot_store.diff(sha_a, sha_b)
|
|
1936
1954
|
respond.call(JSON.pretty_generate({
|
|
1937
1955
|
sha_a: sha_a, sha_b: sha_b,
|
|
@@ -1981,6 +1999,7 @@ module Woods
|
|
|
1981
1999
|
) do |git_sha:, server_context:|
|
|
1982
2000
|
next snap_missing.call('snapshot_detail') unless snapshot_store
|
|
1983
2001
|
|
|
2002
|
+
git_sha = git_sha.downcase
|
|
1984
2003
|
snapshot = snapshot_store.find(git_sha)
|
|
1985
2004
|
if snapshot
|
|
1986
2005
|
respond.call(JSON.pretty_generate(snapshot))
|
|
@@ -2497,8 +2516,9 @@ module Woods
|
|
|
2497
2516
|
|
|
2498
2517
|
target = URI::DEFAULT_PARSER.unescape(raw_target).force_encoding(Encoding::UTF_8)
|
|
2499
2518
|
return unless target.valid_encoding?
|
|
2500
|
-
return if target.match?(
|
|
2501
|
-
return if %w[. ..].include?(
|
|
2519
|
+
return if target.match?(/[%\\\x00-\x1f\x7f]/) || target.start_with?('/')
|
|
2520
|
+
return if target.split('/').any? { |part| %w[. ..].include?(part) }
|
|
2521
|
+
return if parsed.host == 'type' && target.include?('/')
|
|
2502
2522
|
|
|
2503
2523
|
[parsed.host.to_sym, target]
|
|
2504
2524
|
rescue URI::InvalidURIError
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
# Keeps callable app/models modules under one extractor owner as model
|
|
5
|
+
# inclusions change. Partial runtime discovery cannot establish removals.
|
|
6
|
+
module ModuleReconciliation # rubocop:disable Metrics/ModuleLength -- one ownership/provenance policy across writers
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
# @param affected_types [Set<Symbol>] extractor keys requiring index updates
|
|
10
|
+
# @return [Set<String>] added, migrated or removed module identifiers
|
|
11
|
+
def reconcile_model_mixins(affected_types)
|
|
12
|
+
concerns = extractor_for(:concerns)
|
|
13
|
+
return Set.new unless concerns.respond_to?(:runtime_model_mixins)
|
|
14
|
+
|
|
15
|
+
live = concerns.runtime_model_mixins
|
|
16
|
+
live_names = live.values.flatten.to_set(&:name)
|
|
17
|
+
known = @dependency_graph.units_of_type(:concern).to_set
|
|
18
|
+
added = newly_claimed_concerns(concerns, live, known)
|
|
19
|
+
touched = register_and_write(:concerns, added, affected_types)
|
|
20
|
+
touched.merge(remove_claimed_standalone_modules(live_names, affected_types))
|
|
21
|
+
touched.merge(remove_unclaimed_concerns(concerns, known - live_names, affected_types)) if @eager_load_complete
|
|
22
|
+
touched.merge(add_standalone_modules(affected_types))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def newly_claimed_concerns(concerns, live, known)
|
|
26
|
+
live.flat_map do |path, modules|
|
|
27
|
+
next [] if modules.all? { |mod| known.include?(mod.name) }
|
|
28
|
+
|
|
29
|
+
checked_extraction(:concerns, concerns) do
|
|
30
|
+
Array(concerns.extract_model_mixin_file(path)).reject { |unit| known.include?(unit.identifier) }
|
|
31
|
+
end || []
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def remove_claimed_standalone_modules(live_names, affected_types)
|
|
36
|
+
live_names.each_with_object(Set.new) do |identifier, touched|
|
|
37
|
+
next unless standalone_module_identity?(identifier)
|
|
38
|
+
|
|
39
|
+
touched.add(identifier) if remove_unit(identifier, affected_types, type: :poro)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def remove_unclaimed_concerns(concerns, stale, affected_types)
|
|
44
|
+
stale.each_with_object(Set.new) do |identifier, touched|
|
|
45
|
+
path = @dependency_graph.node(identifier, type: :concern)[:file_path]
|
|
46
|
+
next if concerns.conventional_concern_path?(path)
|
|
47
|
+
|
|
48
|
+
touched.add(identifier) if remove_unit(identifier, affected_types, type: :concern)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def runtime_model_mixin_file?(extractor, type, path)
|
|
53
|
+
type == :concern && extractor.respond_to?(:extract_model_mixin_file) &&
|
|
54
|
+
extractor.respond_to?(:conventional_concern_path?) && !extractor.conventional_concern_path?(path)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def add_standalone_modules(affected_types)
|
|
58
|
+
poros = extractor_for(:poros)
|
|
59
|
+
return Set.new unless poros.respond_to?(:standalone_modules)
|
|
60
|
+
|
|
61
|
+
discovered = checked_extraction(:poros, poros) { poros.standalone_modules }
|
|
62
|
+
return Set.new unless discovered
|
|
63
|
+
|
|
64
|
+
added = discovered.values.flatten.reject do |unit|
|
|
65
|
+
@dependency_graph.node(unit.identifier, type: :poro) ||
|
|
66
|
+
@dependency_graph.node(unit.identifier, type: :concern)
|
|
67
|
+
end
|
|
68
|
+
register_and_write(:poros, added, affected_types)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def standalone_module_identity?(identifier)
|
|
72
|
+
return false unless @dependency_graph.node(identifier, type: :poro)
|
|
73
|
+
|
|
74
|
+
path = payload_dir.join('poros', collision_safe_filename(identifier))
|
|
75
|
+
return false unless path.file?
|
|
76
|
+
|
|
77
|
+
JSON.parse(AtomicFile.read(path)).fetch('metadata', {})['ruby_kind'] == 'module'
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A missing includer during fallback boot does not transfer its previously
|
|
81
|
+
# published concern to the PORO extractor. Filter fresh candidates too.
|
|
82
|
+
def authoritative_module_units(units)
|
|
83
|
+
return units if @eager_load_complete
|
|
84
|
+
|
|
85
|
+
units.reject do |unit|
|
|
86
|
+
unit.type == :poro && unit.metadata[:ruby_kind] == 'module' &&
|
|
87
|
+
@dependency_graph.node(unit.identifier, type: :concern)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @param identifier [String] published typed identity
|
|
92
|
+
# @param type [Symbol] current owner
|
|
93
|
+
# @return [Boolean] whether incomplete loading requires retaining this unit
|
|
94
|
+
def retain_partial_module?(identifier, type)
|
|
95
|
+
return false if @eager_load_complete
|
|
96
|
+
|
|
97
|
+
return false unless runtime_module_identity?(identifier, type)
|
|
98
|
+
return false if type == :poro && live_concern_identity?(identifier)
|
|
99
|
+
|
|
100
|
+
verify_retained_module_source!(identifier, type)
|
|
101
|
+
true
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def runtime_module_identity?(identifier, type)
|
|
105
|
+
return standalone_module_identity?(identifier) if type == :poro
|
|
106
|
+
return false unless type == :concern
|
|
107
|
+
|
|
108
|
+
concerns = extractor_for(:concerns)
|
|
109
|
+
path = @dependency_graph.node(identifier, type: type)[:file_path]
|
|
110
|
+
concerns.respond_to?(:conventional_concern_path?) && !concerns.conventional_concern_path?(path)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def live_concern_identity?(identifier)
|
|
114
|
+
concerns = extractor_for(:concerns)
|
|
115
|
+
concerns.respond_to?(:runtime_model_mixins) &&
|
|
116
|
+
concerns.runtime_model_mixins.values.any? { |modules| modules.any? { |mod| mod.name == identifier } }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Cache identities predate every consumption update in this run. A freshly
|
|
120
|
+
# written shared-file sibling must not certify retained module metadata as
|
|
121
|
+
# having consumed changed source during incomplete eager loading.
|
|
122
|
+
def verify_retained_module_source!(identifier, type)
|
|
123
|
+
return unless @source_inputs
|
|
124
|
+
|
|
125
|
+
path = @dependency_graph.node(identifier, type: type)[:file_path]
|
|
126
|
+
relative = File.expand_path(path, Rails.root).delete_prefix("#{File.expand_path(Rails.root)}/")
|
|
127
|
+
before = @source_reference_baseline&.dig('files', relative, 'identity')
|
|
128
|
+
return if before && before == @source_inputs.source_identity(path)
|
|
129
|
+
|
|
130
|
+
raise SourceReferences::RebuildRequired,
|
|
131
|
+
"Cannot refresh retained #{type}:#{identifier} after incomplete eager loading. " \
|
|
132
|
+
'Retry in a fresh Rails process with a complete eager load; the previous generation remains active.'
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def consume_module_aware_refresh(key, units)
|
|
136
|
+
if !@eager_load_complete && %i[poros concerns].include?(key)
|
|
137
|
+
@source_inputs&.unverified("extractor:#{key}")
|
|
138
|
+
else
|
|
139
|
+
@source_inputs&.consume_extractor(key, units)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# These formerly file-only families now retain runtime-discovered modules
|
|
144
|
+
# on a partial boot, so their replacement authority follows the same gate.
|
|
145
|
+
def replacement_discovery_complete?(key)
|
|
146
|
+
return @eager_load_complete if %i[poros concerns].include?(key)
|
|
147
|
+
|
|
148
|
+
super
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -165,7 +165,7 @@ module Woods
|
|
|
165
165
|
file_rule(:migrations, :extract_migration_file, %w[db/migrate], recursive: false),
|
|
166
166
|
# POROs are app/models classes that are *not* ActiveRecord models;
|
|
167
167
|
# the extractor makes that call itself given ar_names.
|
|
168
|
-
file_rule(:poros, :
|
|
168
|
+
file_rule(:poros, :extract_poro_units, %w[app/models], exclude: %w[/concerns/]),
|
|
169
169
|
file_rule(:libs, :extract_lib_file, %w[lib], exclude: ex::LibExtractor::EXCLUDED_SEGMENTS),
|
|
170
170
|
file_rule(:test_mappings, :extract_test_file, %w[spec], extensions: %w[_spec.rb]),
|
|
171
171
|
file_rule(:test_mappings, :extract_test_file, %w[test], extensions: %w[_test.rb])
|
|
@@ -184,11 +184,16 @@ module Woods
|
|
|
184
184
|
Woods::Extractors::ViewTemplateExtractor::ENGINES.flat_map { |k| k.new.extensions }.uniq
|
|
185
185
|
end
|
|
186
186
|
|
|
187
|
+
def aggregate_source_rules
|
|
188
|
+
[whole_app_rule(:libs, %w[lib], extensions: %w[.rb], exclude: Woods::Extractors::LibExtractor::EXCLUDED_SEGMENTS),
|
|
189
|
+
whole_app_rule(:rake_tasks, Woods::Extractors::RakeTaskExtractor::RAKE_DIRECTORIES, extensions: %w[.rake])]
|
|
190
|
+
end
|
|
191
|
+
|
|
187
192
|
def build_whole_app_rules
|
|
188
193
|
[
|
|
189
194
|
# A task may combine definitions from several files; any change or
|
|
190
195
|
# deletion must reconcile the complete task set, not its primary file.
|
|
191
|
-
|
|
196
|
+
*aggregate_source_rules,
|
|
192
197
|
whole_app_rule(:routes, %w[config/routes], exact_paths: %w[config/routes.rb]),
|
|
193
198
|
whole_app_rule(:engines, %w[config/routes], exact_paths: %w[config/routes.rb Gemfile.lock]),
|
|
194
199
|
whole_app_rule(:middleware, %w[config/initializers config/environments],
|
|
@@ -114,6 +114,7 @@ module Woods
|
|
|
114
114
|
warn_late_console_path(config)
|
|
115
115
|
return unless config.console_mcp_enabled && config.console_mcp_http_enabled
|
|
116
116
|
|
|
117
|
+
verify_console_origins!
|
|
117
118
|
require 'woods/mcp/bearer_auth'
|
|
118
119
|
token = config.console_mcp_token.to_s
|
|
119
120
|
if token.empty?
|
|
@@ -147,6 +148,13 @@ module Woods
|
|
|
147
148
|
|
|
148
149
|
private
|
|
149
150
|
|
|
151
|
+
def verify_console_origins!
|
|
152
|
+
require 'woods/mcp/origin_policy'
|
|
153
|
+
Woods::MCP::OriginPolicy.new(allowed_origins: config.console_mcp_allowed_origins)
|
|
154
|
+
rescue ArgumentError => e
|
|
155
|
+
raise Woods::ConfigurationError, "[Woods Console] #{e.message}"
|
|
156
|
+
end
|
|
157
|
+
|
|
150
158
|
# Warn when the configured console path no longer matches the path the
|
|
151
159
|
# stack was mounted at — the path was set after railtie initializers
|
|
152
160
|
# captured the middleware arguments.
|
data/lib/woods/rake_helpers.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'woods/atomic_file'
|
|
4
|
+
require 'woods/change_set'
|
|
4
5
|
require 'woods/generation'
|
|
5
6
|
require 'woods/git_command'
|
|
6
7
|
|
|
@@ -9,6 +10,16 @@ module Woods
|
|
|
9
10
|
module RakeHelpers # rubocop:disable Metrics/ModuleLength -- existing task helpers grouped without changing behavior
|
|
10
11
|
module_function
|
|
11
12
|
|
|
13
|
+
# @return [Float, nil] blank values leave the raw task resident
|
|
14
|
+
def woods_watch_idle_timeout
|
|
15
|
+
value = ENV['WOODS_WATCH_IDLE_TIMEOUT'].to_s.strip
|
|
16
|
+
return if value.empty?
|
|
17
|
+
|
|
18
|
+
Float(value)
|
|
19
|
+
rescue ArgumentError, TypeError
|
|
20
|
+
raise ArgumentError, 'WOODS_WATCH_IDLE_TIMEOUT must be a number of seconds or blank'
|
|
21
|
+
end
|
|
22
|
+
|
|
12
23
|
# ── Multi-instance helpers (#164 phase 4) ────────────────────────────────
|
|
13
24
|
#
|
|
14
25
|
# Worktrees are disjoint by construction (each has its own Rails.root and
|
|
@@ -59,6 +70,11 @@ module Woods
|
|
|
59
70
|
end
|
|
60
71
|
|
|
61
72
|
def woods_with_extraction_lock(output_dir, wait: nil, raise_on_timeout: false, &block)
|
|
73
|
+
require 'woods/source_inputs/handoff'
|
|
74
|
+
if ENV[Woods::SourceInputs::Handoff::ENV_KEY]
|
|
75
|
+
Woods::SourceInputs::Handoff.validate_output!(root: woods_task_root, output_dir: output_dir)
|
|
76
|
+
end
|
|
77
|
+
|
|
62
78
|
# Requires first. The default wait reads a constant from the daemon, so
|
|
63
79
|
# resolving it above these lines NameError'd every write task — the same
|
|
64
80
|
# load-order bug as the missing require in `woods:watch`, reintroduced one
|
|
@@ -195,7 +211,8 @@ module Woods
|
|
|
195
211
|
def woods_sweep_index_dir(output_dir, lock_name)
|
|
196
212
|
preserved = [
|
|
197
213
|
output_dir.join("#{lock_name}.lock"),
|
|
198
|
-
output_dir.join(Woods::Coordination::PipelineLock.guard_filename(lock_name))
|
|
214
|
+
output_dir.join(Woods::Coordination::PipelineLock.guard_filename(lock_name)),
|
|
215
|
+
*['', '.lock', '.lease'].map { |suffix| output_dir.join("#{Woods::Watch::ClaimLease::CLAIM}#{suffix}") }
|
|
199
216
|
]
|
|
200
217
|
output_dir.children.each do |entry|
|
|
201
218
|
FileUtils.rm_rf(entry) unless preserved.include?(entry)
|
|
@@ -235,8 +252,9 @@ module Woods
|
|
|
235
252
|
# naming both.
|
|
236
253
|
#
|
|
237
254
|
# The diff is rooted at the extracted application (`git -C Rails.root`),
|
|
238
|
-
# consistent with {Woods::GitProvenance} (#262)
|
|
239
|
-
#
|
|
255
|
+
# consistent with {Woods::GitProvenance} (#262). Explicit `--relative` and
|
|
256
|
+
# the current-directory pathspec keep a nested application's paths relative
|
|
257
|
+
# to Rails.root and exclude sibling applications in the same repository.
|
|
240
258
|
#
|
|
241
259
|
# The child status is carried out, not discarded (M1): `Open3.capture2`
|
|
242
260
|
# turned an unresolvable range — a GitLab zero-SHA, an unfetched GitHub base
|
|
@@ -245,7 +263,7 @@ module Woods
|
|
|
245
263
|
# never ran.
|
|
246
264
|
#
|
|
247
265
|
# @param range [String] a git diff range/revision expression
|
|
248
|
-
# @param root [Pathname, String]
|
|
266
|
+
# @param root [Pathname, String] application root the diff runs against
|
|
249
267
|
# @return [Array(Array<String>, String, nil)] the changed paths (both halves
|
|
250
268
|
# of any rename included), or nil paths plus a human-readable failure when
|
|
251
269
|
# the range could not be resolved
|
|
@@ -254,7 +272,7 @@ module Woods
|
|
|
254
272
|
output, error, status = Open3.capture3(
|
|
255
273
|
*Woods::GitCommand.argv(
|
|
256
274
|
root, '-c', 'core.quotePath=false',
|
|
257
|
-
'diff', '--name-status', '-z', '--no-renames', range
|
|
275
|
+
'diff', '--relative', '--name-status', '-z', '--no-renames', range, '--', '.'
|
|
258
276
|
)
|
|
259
277
|
)
|
|
260
278
|
return [woods_parse_git_diff_name_status(output), nil] if status.success?
|
|
@@ -289,11 +307,11 @@ module Woods
|
|
|
289
307
|
# @return [Array<String>] changed paths
|
|
290
308
|
def woods_incremental_changed_paths(output_dir)
|
|
291
309
|
explicit = ENV.fetch('CHANGED_FILES', nil)
|
|
292
|
-
return explicit.split(',')
|
|
310
|
+
return woods_normalize_incremental_paths(explicit.split(',')) if explicit
|
|
293
311
|
|
|
294
312
|
range = woods_incremental_range
|
|
295
313
|
changed_files, failure = woods_changed_paths_for_range(range)
|
|
296
|
-
return changed_files unless failure
|
|
314
|
+
return woods_normalize_incremental_paths(changed_files) unless failure
|
|
297
315
|
|
|
298
316
|
if woods_daemon_coverage(output_dir) == :running
|
|
299
317
|
puts "Could not resolve the git diff range #{range.inspect} (#{failure})."
|
|
@@ -309,17 +327,31 @@ module Woods
|
|
|
309
327
|
exit 1
|
|
310
328
|
end
|
|
311
329
|
|
|
330
|
+
# Normalize before the task filters against application-relative rules.
|
|
331
|
+
# ChangeSet retains outside-root paths as absolute; exclude those here so
|
|
332
|
+
# traversal spellings cannot reach extraction as application source. This
|
|
333
|
+
# is lexical containment, preserving deleted files and symlink spellings.
|
|
334
|
+
#
|
|
335
|
+
# @param paths [Array<String>] absolute or application-relative paths
|
|
336
|
+
# @return [Array<String>] unique, contained application-relative paths
|
|
337
|
+
def woods_normalize_incremental_paths(paths)
|
|
338
|
+
ChangeSet.new(paths: paths, root: Rails.root).relative_paths.reject { |path| Pathname.new(path).absolute? }
|
|
339
|
+
end
|
|
340
|
+
|
|
312
341
|
# The git range `woods:incremental` diffs, from the CI environment or the
|
|
313
342
|
# last-commit default.
|
|
314
343
|
#
|
|
315
344
|
# @return [String]
|
|
316
345
|
def woods_incremental_range
|
|
317
|
-
|
|
346
|
+
before = ENV['CI_COMMIT_BEFORE_SHA'].to_s.strip
|
|
347
|
+
current = ENV['CI_COMMIT_SHA'].to_s.strip
|
|
348
|
+
base = ENV['GITHUB_BASE_REF'].to_s.strip
|
|
349
|
+
if !before.empty?
|
|
318
350
|
# GitLab CI
|
|
319
|
-
"#{
|
|
320
|
-
elsif
|
|
351
|
+
"#{before}..#{current.empty? ? 'HEAD' : current}"
|
|
352
|
+
elsif !base.empty?
|
|
321
353
|
# GitHub Actions PR
|
|
322
|
-
"origin/#{
|
|
354
|
+
"origin/#{base}...HEAD"
|
|
323
355
|
else
|
|
324
356
|
# Default: changes since last commit
|
|
325
357
|
'HEAD~1'
|
data/lib/woods/release.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Woods
|
|
4
4
|
# Maintainer-only release machinery, deliberately not packaged with the gem.
|
|
5
|
-
# These files back `release:prepare` and `release:
|
|
5
|
+
# These files back `release:prepare`, `release:reopen`, and `release:retarget`, which run
|
|
6
6
|
# from a source checkout of this repository.
|
|
7
7
|
module Release
|
|
8
8
|
# Every refusal the release flow raises. `lib/tasks/release.rake` rescues
|
|
@@ -132,19 +132,24 @@ module Woods
|
|
|
132
132
|
|
|
133
133
|
warn_unresolvable_paths(warnings, unresolvable)
|
|
134
134
|
validate_dependency_graph(payload, errors)
|
|
135
|
-
validate_source_inputs(payload, errors)
|
|
135
|
+
validate_source_inputs(payload, errors, warnings)
|
|
136
136
|
end
|
|
137
137
|
|
|
138
138
|
# Optional for old generations; malformed new provenance is an artifact
|
|
139
139
|
# integrity error. Source drift itself is advisory and belongs to status.
|
|
140
|
-
def validate_source_inputs(payload, errors)
|
|
140
|
+
def validate_source_inputs(payload, errors, warnings)
|
|
141
141
|
path = File.join(payload, SourceInputs::Manifest::FILE_NAME)
|
|
142
142
|
return unless File.exist?(path)
|
|
143
143
|
|
|
144
144
|
File.open(path, File::RDONLY | File::NONBLOCK) do |file|
|
|
145
145
|
raise SourceInputs::Manifest::Invalid unless file.stat.file?
|
|
146
146
|
|
|
147
|
-
SourceInputs::Manifest.parse(file.read(SourceInputs::Manifest::MAX_BYTES + 1))
|
|
147
|
+
manifest = SourceInputs::Manifest.parse(file.read(SourceInputs::Manifest::MAX_BYTES + 1))
|
|
148
|
+
if manifest.unavailable?
|
|
149
|
+
evidence = manifest.data.fetch('unavailable')
|
|
150
|
+
warnings << "Source freshness unavailable: #{evidence['reason']} (#{evidence['size_bytes']} bytes, " \
|
|
151
|
+
"limit #{evidence['limit_bytes']}); the code index remains usable"
|
|
152
|
+
end
|
|
148
153
|
end
|
|
149
154
|
rescue SourceInputs::Manifest::Invalid, SystemCallError, IOError
|
|
150
155
|
errors << 'Invalid source_inputs.json provenance artifact'
|
|
@@ -81,6 +81,23 @@ module Woods
|
|
|
81
81
|
@provider.model_name
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
+
# Optional pure configuration contracts; neither may probe the provider.
|
|
85
|
+
def cache_identity
|
|
86
|
+
@provider.cache_identity if @provider.respond_to?(:cache_identity)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def input_budget
|
|
90
|
+
@provider.input_budget if @provider.respond_to?(:input_budget)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def configured_dimensions
|
|
94
|
+
@provider.configured_dimensions if @provider.respond_to?(:configured_dimensions)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def requested_dimensions
|
|
98
|
+
@provider.requested_dimensions if @provider.respond_to?(:requested_dimensions)
|
|
99
|
+
end
|
|
100
|
+
|
|
84
101
|
# Delegate the per-provider input cap. The retry wrapper does not
|
|
85
102
|
# change the provider's budget, so just hand through whatever the
|
|
86
103
|
# inner provider reports. `respond_to?` alone is the wrong guard
|
|
@@ -135,7 +152,7 @@ module Woods
|
|
|
135
152
|
begin
|
|
136
153
|
attempt += 1
|
|
137
154
|
yield
|
|
138
|
-
rescue CircuitOpenError, ArgumentError
|
|
155
|
+
rescue CircuitOpenError, ArgumentError, Embedding::InputLimitError
|
|
139
156
|
raise
|
|
140
157
|
rescue StandardError => e
|
|
141
158
|
raise e if attempt > @max_retries || !retryable_error?(e)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'time'
|
|
5
|
+
require 'uri'
|
|
5
6
|
require_relative 'mcp/errors'
|
|
6
7
|
|
|
7
8
|
module Woods
|
|
@@ -29,6 +30,12 @@ module Woods
|
|
|
29
30
|
# The only schema version this gem release can read or write.
|
|
30
31
|
SCHEMA_VERSION_SUPPORTED = 1
|
|
31
32
|
|
|
33
|
+
BUILTIN_PROVIDERS = {
|
|
34
|
+
'Woods::Embedding::Provider::OpenAI' => :openai,
|
|
35
|
+
'Woods::Embedding::Provider::Ollama' => :ollama,
|
|
36
|
+
'Woods::Embedding::Provider::Fake' => :fake
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
32
39
|
# @return [Integer]
|
|
33
40
|
attr_reader :schema_version
|
|
34
41
|
|
|
@@ -38,7 +45,7 @@ module Woods
|
|
|
38
45
|
# @return [Time]
|
|
39
46
|
attr_reader :created_at
|
|
40
47
|
|
|
41
|
-
# @return [Hash] Provider details
|
|
48
|
+
# @return [Hash] Provider details, including observed :dimension and :requested_dimensions
|
|
42
49
|
attr_reader :embedding_provider
|
|
43
50
|
|
|
44
51
|
# @return [Hash] Store types — :vector_store, :metadata_store, :graph_store (Symbols)
|
|
@@ -74,9 +81,9 @@ module Woods
|
|
|
74
81
|
# the model, not the config — and doesn't hurt OpenAI, whose provider
|
|
75
82
|
# exposes the same +#dimensions+ interface.
|
|
76
83
|
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
84
|
+
# Known injected providers expose pure constructor settings, including a
|
|
85
|
+
# configured or already observed width, without probing. Otherwise an
|
|
86
|
+
# omitted +provider:+ uses the declared embedding options for offline capture.
|
|
80
87
|
#
|
|
81
88
|
# @param config [Woods::Configuration]
|
|
82
89
|
# @param gem_version [String] Defaults to {Woods::VERSION}
|
|
@@ -87,7 +94,11 @@ module Woods
|
|
|
87
94
|
require_relative 'version'
|
|
88
95
|
|
|
89
96
|
opts = (config.embedding_options || {}).transform_keys(&:to_sym)
|
|
90
|
-
|
|
97
|
+
concrete = unwrap_provider(provider || config.embedding_provider)
|
|
98
|
+
if BUILTIN_PROVIDERS.key?(concrete.class.name) && concrete.respond_to?(:configuration_options)
|
|
99
|
+
opts = concrete.configuration_options
|
|
100
|
+
end
|
|
101
|
+
declared_dim = opts[:expected_dimensions] || opts[:dimensions] || opts[:dimension] || opts[:dims]
|
|
91
102
|
dim = provider.respond_to?(:dimensions) ? provider.dimensions : declared_dim
|
|
92
103
|
model = provider.respond_to?(:model_name) ? provider.model_name : (opts[:model] || config.embedding_model)
|
|
93
104
|
|
|
@@ -99,6 +110,11 @@ module Woods
|
|
|
99
110
|
num_ctx: opts[:num_ctx],
|
|
100
111
|
read_timeout: opts[:read_timeout]
|
|
101
112
|
}.compact
|
|
113
|
+
provider_hash[:requested_dimensions] = if provider.respond_to?(:requested_dimensions)
|
|
114
|
+
provider.requested_dimensions
|
|
115
|
+
else
|
|
116
|
+
opts[:dimensions] || opts[:dimension]
|
|
117
|
+
end
|
|
102
118
|
|
|
103
119
|
new(
|
|
104
120
|
schema_version: SCHEMA_VERSION_SUPPORTED,
|
|
@@ -123,7 +139,7 @@ module Woods
|
|
|
123
139
|
@schema_version = schema_version
|
|
124
140
|
@gem_version = gem_version.to_s.freeze
|
|
125
141
|
@created_at = created_at
|
|
126
|
-
@embedding_provider = deep_freeze(embedding_provider)
|
|
142
|
+
@embedding_provider = deep_freeze(self.class.sanitize_provider(embedding_provider))
|
|
127
143
|
@stores = deep_freeze(stores)
|
|
128
144
|
@store_options = deep_freeze(store_options)
|
|
129
145
|
freeze
|
|
@@ -206,6 +222,22 @@ module Woods
|
|
|
206
222
|
to_snapshot_json.freeze
|
|
207
223
|
end
|
|
208
224
|
|
|
225
|
+
# Only known built-ins with safely recorded settings can be reconstructed.
|
|
226
|
+
# An explicitly configured host provider may still use this snapshot.
|
|
227
|
+
def requires_host_provider?
|
|
228
|
+
embedding_provider[:requires_host_provider] == true
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def self.sanitize_provider(provider)
|
|
232
|
+
result = provider.dup
|
|
233
|
+
result[:requires_host_provider] = true unless BUILTIN_PROVIDERS.key?(result[:class])
|
|
234
|
+
if result[:host] && !safe_provider_host?(result[:host])
|
|
235
|
+
result.delete(:host)
|
|
236
|
+
result[:requires_host_provider] = true
|
|
237
|
+
end
|
|
238
|
+
result
|
|
239
|
+
end
|
|
240
|
+
|
|
209
241
|
private
|
|
210
242
|
|
|
211
243
|
def stringify_nested_keys(value)
|
|
@@ -289,7 +321,7 @@ module Woods
|
|
|
289
321
|
|
|
290
322
|
def parse_provider(raw)
|
|
291
323
|
data = normalize_keys(raw)
|
|
292
|
-
{
|
|
324
|
+
parsed = {
|
|
293
325
|
class: data[:class].to_s,
|
|
294
326
|
model: data[:model].to_s,
|
|
295
327
|
dimension: data[:dimension].to_i,
|
|
@@ -297,6 +329,19 @@ module Woods
|
|
|
297
329
|
num_ctx: data[:num_ctx],
|
|
298
330
|
read_timeout: data[:read_timeout]
|
|
299
331
|
}.compact
|
|
332
|
+
parsed[:requested_dimensions] = data[:requested_dimensions] if data.key?(:requested_dimensions)
|
|
333
|
+
parsed[:requires_host_provider] = true if data[:requires_host_provider] == true
|
|
334
|
+
parsed
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Paths and URL metadata can contain credentials. Keep only plain origins;
|
|
338
|
+
# do not strip a prefix/query and silently reconstruct a different service.
|
|
339
|
+
def safe_provider_host?(host)
|
|
340
|
+
uri = URI.parse(host)
|
|
341
|
+
uri.is_a?(URI::HTTP) && uri.host && !uri.userinfo && !uri.query && !uri.fragment &&
|
|
342
|
+
['', '/'].include?(uri.path)
|
|
343
|
+
rescue URI::InvalidURIError, ArgumentError, TypeError
|
|
344
|
+
false
|
|
300
345
|
end
|
|
301
346
|
|
|
302
347
|
def parse_stores(raw)
|
|
@@ -338,8 +383,23 @@ module Woods
|
|
|
338
383
|
when String then provider
|
|
339
384
|
when Class then provider.name
|
|
340
385
|
when nil then ''
|
|
341
|
-
else provider
|
|
386
|
+
else resolve_provider_object_class(provider)
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def resolve_provider_object_class(provider)
|
|
391
|
+
unwrap_provider(provider).class.name || '(anonymous provider)'
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def unwrap_provider(provider)
|
|
395
|
+
seen = {}.compare_by_identity
|
|
396
|
+
while %w[Woods::Resilience::RetryableProvider Woods::Cache::CachedEmbeddingProvider].include?(provider.class.name)
|
|
397
|
+
raise Woods::MCP::ConfigMismatch, 'Embedding provider wrappers contain a cycle' if seen[provider]
|
|
398
|
+
|
|
399
|
+
seen[provider] = true
|
|
400
|
+
provider = provider.provider
|
|
342
401
|
end
|
|
402
|
+
provider
|
|
343
403
|
end
|
|
344
404
|
end
|
|
345
405
|
end
|
|
@@ -342,7 +342,7 @@ module Woods
|
|
|
342
342
|
|
|
343
343
|
def append_compact_candidate(parts, sources, candidate, unit, budget, tokens_used)
|
|
344
344
|
header = "## #{unit_field(unit, :identifier)} (#{unit_field(unit, :type)})\n" \
|
|
345
|
-
"
|
|
345
|
+
"#{SourceContributors.label(unit)}\n\n"
|
|
346
346
|
remaining = budget - tokens_used
|
|
347
347
|
return tokens_used unless remaining.positive?
|
|
348
348
|
|
|
@@ -365,12 +365,11 @@ module Woods
|
|
|
365
365
|
def format_unit(unit, _candidate)
|
|
366
366
|
identifier = unit_field(unit, :identifier)
|
|
367
367
|
type = unit_field(unit, :type)
|
|
368
|
-
file_path = unit_field(unit, :file_path)
|
|
369
368
|
source = unit_field(unit, :source_code) || ''
|
|
370
369
|
|
|
371
370
|
<<~UNIT.strip
|
|
372
371
|
## #{identifier} (#{type})
|
|
373
|
-
|
|
372
|
+
#{SourceContributors.label(unit)}
|
|
374
373
|
|
|
375
374
|
#{source}
|
|
376
375
|
UNIT
|
|
@@ -386,6 +385,7 @@ module Woods
|
|
|
386
385
|
score: candidate.score,
|
|
387
386
|
file_path: unit_field(unit, :file_path)
|
|
388
387
|
}
|
|
388
|
+
attribution.merge!(SourceContributors.attribution(unit))
|
|
389
389
|
attribution[:truncated] = true if truncated
|
|
390
390
|
attribution
|
|
391
391
|
end
|