woods 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec72d151147ef4b43df866b6ebee48f52a26915efdb44df7bed08c24646cfb7a
4
- data.tar.gz: f03a813c3f525eeba7a95269770ee80ee4c4fd91017561529800745a241cfd9c
3
+ metadata.gz: 927abae1f4f641405384261569e1d25f94a672ca986d1c50093b3f6a56b7db38
4
+ data.tar.gz: fa35b4320669d195a8e4f377400b6999e735aebf5447071ee3353eaa8856840b
5
5
  SHA512:
6
- metadata.gz: 75608caf708f2f4ef913653af543e983ac68abb9ab0028f9e967bda13af4e5b0c47f73a1d440696b453fb08afc69ab56cf2d70d366aa29949676e29abb6cdc85
7
- data.tar.gz: a0f0e086045967cee236f4d98bd6131e5ca8c99daf7a150aa395c60dc56665bb3f4cc5a615d101480c1f8da752fd38a35893662ebf4cda02f49f008aeb6bc081
6
+ metadata.gz: 5ae6ef3436f6aa6b936b46103480e797a8a6e0fb4250f5dcc8bc721c2b9b911739e5d5aebd5b8b97c6788d58dcd19e9dbd5c6211a3400283e60084ce80c6d031
7
+ data.tar.gz: 6b38946aca86d407ab6d516d32dda4c5797adfcd27249b1685bdebb249ff34e71d62e3eabb266c991d1b32ad2df815e2a56dc924615c1def9df0c4c6754cd629
data/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.0] - 2026-03-27
9
+
10
+ ### Added
11
+
12
+ - **Unblocked Documents API exporter** — sync extraction data to an Unblocked collection for code review and Q&A context
13
+ - `Woods::Unblocked::Client` — REST client with retry and daily budget rate limiting
14
+ - `Woods::Unblocked::DocumentBuilder` — type-specific Markdown formatters optimized for review context (blast radius, entry points, associations, side effects)
15
+ - `Woods::Unblocked::Exporter` — full/partial sync orchestrator with priority ordering
16
+ - `Woods::Unblocked::RateLimiter` — daily budget tracking (1000 calls/day)
17
+ - New rake tasks: `woods:unblocked_sync` (alias: `woods:relay`)
18
+ - New config: `unblocked_api_token`, `unblocked_collection_id`, `unblocked_repo_url`
19
+ - Integration guide: `docs/UNBLOCKED_INTEGRATION.md`
20
+ - **Domain cluster detection** in `GraphAnalyzer` — groups code units into semantic domains using namespace prefixes and graph connectivity
21
+ - `GraphAnalyzer#domain_clusters` — hybrid namespace + graph clustering with hub identification, entry point detection, and boundary edge mapping
22
+ - New MCP tool: `domain_clusters` with `min_size` and `types` filters
23
+ - New renderer: `render_domain_clusters` in MarkdownRenderer
24
+
8
25
  ## [0.3.1] - 2026-03-04
9
26
 
10
27
  ### Fixed
data/lib/tasks/woods.rake CHANGED
@@ -618,4 +618,58 @@ namespace :woods do
618
618
 
619
619
  desc 'Send findings from the field — sync to Notion (alias for notion_sync)'
620
620
  task send: :notion_sync
621
+
622
+ desc 'Sync extraction data to Unblocked collection (Documents API)'
623
+ task unblocked_sync: :environment do
624
+ require 'woods/unblocked/exporter'
625
+
626
+ config = Woods.configuration
627
+ config.unblocked_api_token = ENV.fetch('UNBLOCKED_API_TOKEN', nil) || config.unblocked_api_token
628
+ config.unblocked_collection_id = ENV.fetch('UNBLOCKED_COLLECTION_ID', nil) || config.unblocked_collection_id
629
+ config.unblocked_repo_url = ENV.fetch('UNBLOCKED_REPO_URL', nil) || config.unblocked_repo_url
630
+
631
+ unless config.unblocked_api_token
632
+ puts 'ERROR: Unblocked API token not configured.'
633
+ puts 'Set UNBLOCKED_API_TOKEN env var or configure unblocked_api_token in Woods.configure.'
634
+ exit 1
635
+ end
636
+
637
+ unless config.unblocked_collection_id
638
+ puts 'ERROR: Unblocked collection ID not configured.'
639
+ puts 'Set UNBLOCKED_COLLECTION_ID env var or configure unblocked_collection_id in Woods.configure.'
640
+ exit 1
641
+ end
642
+
643
+ unless config.unblocked_repo_url
644
+ puts 'ERROR: Repository URL not configured.'
645
+ puts 'Set UNBLOCKED_REPO_URL env var or configure unblocked_repo_url in Woods.configure.'
646
+ puts 'Example: https://github.com/your-org/your-repo'
647
+ exit 1
648
+ end
649
+
650
+ output_dir = ENV.fetch('WOODS_OUTPUT', config.output_dir)
651
+
652
+ puts 'Syncing extraction data to Unblocked...'
653
+ puts " Output dir: #{output_dir}"
654
+ puts " Collection: #{config.unblocked_collection_id}"
655
+ puts " Repo URL: #{config.unblocked_repo_url}"
656
+ puts
657
+
658
+ exporter = Woods::Unblocked::Exporter.new(index_dir: output_dir)
659
+ stats = exporter.sync_all
660
+
661
+ puts
662
+ puts 'Sync complete!'
663
+ puts " Documents synced: #{stats[:synced]}"
664
+ puts " Documents skipped: #{stats[:skipped]}"
665
+
666
+ if stats[:errors].any?
667
+ puts " Errors: #{stats[:errors].size}"
668
+ stats[:errors].first(5).each { |e| puts " - #{e}" }
669
+ puts " ... and #{stats[:errors].size - 5} more" if stats[:errors].size > 5
670
+ end
671
+ end
672
+
673
+ desc 'Relay findings to Unblocked (alias for unblocked_sync)'
674
+ task relay: :unblocked_sync
621
675
  end
@@ -154,6 +154,52 @@ module Woods
154
154
  end
155
155
  end
156
156
 
157
+ # Group units into semantic domains using namespace prefixes and graph connectivity.
158
+ #
159
+ # Strategy:
160
+ # 1. Seed clusters from top-level namespace prefixes (e.g., ShippingProfile::*, Order::*)
161
+ # 2. Assign unnamespaced units to their most-connected cluster
162
+ # 3. Merge small clusters (< min_size) into their most-connected neighbor
163
+ # 4. For each cluster, identify the hub (highest PageRank) and entry points
164
+ # 5. Compute boundary edges between clusters
165
+ #
166
+ # @param min_size [Integer] Minimum units per cluster before merging (default: 3)
167
+ # @param types [Array<String>, nil] Filter to these unit types (default: all)
168
+ # @return [Array<Hash>] Clusters sorted by member count descending.
169
+ # Each hash: { name:, hub:, members:, member_count:, entry_points:, boundary_edges:, types: }
170
+ def domain_clusters(min_size: 3, types: nil)
171
+ nodes = graph_nodes
172
+ return [] if nodes.empty?
173
+
174
+ # Filter by types if specified
175
+ filtered_ids = if types
176
+ type_set = types.map(&:to_s)
177
+ nodes.select { |_, meta| type_set.include?(meta[:type].to_s) }.keys
178
+ else
179
+ nodes.keys
180
+ end
181
+
182
+ return [] if filtered_ids.empty?
183
+
184
+ # Step 1: Seed clusters from namespace prefixes
185
+ clusters = seed_namespace_clusters(filtered_ids, nodes)
186
+
187
+ # Step 2: Assign unnamespaced/root units to most-connected cluster
188
+ assign_orphaned_units(clusters, filtered_ids, nodes)
189
+
190
+ # Step 3: Merge small clusters
191
+ merge_small_clusters(clusters, min_size)
192
+
193
+ # Step 4: Enrich each cluster with hub, entry points, boundary edges
194
+ pagerank_scores = @graph.pagerank
195
+ enrich_clusters(clusters, nodes, pagerank_scores)
196
+
197
+ # Sort by member count descending
198
+ clusters.values
199
+ .select { |c| c[:members].any? }
200
+ .sort_by { |c| -c[:member_count] }
201
+ end
202
+
157
203
  # Full analysis report combining all structural metrics.
158
204
  #
159
205
  # @return [Hash] Complete analysis with :orphans, :dead_ends, :hubs,
@@ -182,6 +228,171 @@ module Woods
182
228
 
183
229
  private
184
230
 
231
+ # ──────────────────────────────────────────────────────────────────────
232
+ # Domain Cluster Helpers
233
+ # ──────────────────────────────────────────────────────────────────────
234
+
235
+ # Extract the top-level namespace prefix for clustering.
236
+ # "ShippingProfile::Setting" => "ShippingProfile"
237
+ # "Order::Transactions::Refund" => "Order"
238
+ # "Account" => nil (no namespace)
239
+ def cluster_prefix(identifier)
240
+ parts = identifier.to_s.split('::')
241
+ parts.size > 1 ? parts.first : nil
242
+ end
243
+
244
+ # Seed initial clusters from namespace prefixes.
245
+ def seed_namespace_clusters(filtered_ids, _nodes)
246
+ clusters = {}
247
+
248
+ filtered_ids.each do |id|
249
+ prefix = cluster_prefix(id)
250
+ next unless prefix
251
+
252
+ clusters[prefix] ||= { name: prefix, members: [], member_set: Set.new }
253
+ clusters[prefix][:members] << id
254
+ clusters[prefix][:member_set].add(id)
255
+ end
256
+
257
+ clusters
258
+ end
259
+
260
+ # Assign units with no namespace prefix to their most-connected cluster.
261
+ def assign_orphaned_units(clusters, filtered_ids, _nodes)
262
+ return if clusters.empty?
263
+
264
+ unassigned = filtered_ids.select { |id| cluster_prefix(id).nil? }
265
+
266
+ unassigned.each do |id|
267
+ best_cluster = find_most_connected_cluster(id, clusters)
268
+ next unless best_cluster
269
+
270
+ clusters[best_cluster][:members] << id
271
+ clusters[best_cluster][:member_set].add(id)
272
+ end
273
+ end
274
+
275
+ # Find which cluster a unit has the most connections to.
276
+ def find_most_connected_cluster(identifier, clusters)
277
+ connections = Hash.new(0)
278
+
279
+ # Check forward edges (dependencies)
280
+ @graph.dependencies_of(identifier).each do |dep|
281
+ clusters.each do |name, cluster|
282
+ connections[name] += 1 if cluster[:member_set].include?(dep)
283
+ end
284
+ end
285
+
286
+ # Check reverse edges (dependents)
287
+ @graph.dependents_of(identifier).each do |dep|
288
+ clusters.each do |name, cluster|
289
+ connections[name] += 1 if cluster[:member_set].include?(dep)
290
+ end
291
+ end
292
+
293
+ return nil if connections.empty?
294
+
295
+ connections.max_by { |_, count| count }.first
296
+ end
297
+
298
+ # Merge clusters smaller than min_size into their most-connected neighbor.
299
+ def merge_small_clusters(clusters, min_size)
300
+ loop do
301
+ small = clusters.select { |_, c| c[:members].size < min_size }
302
+ break if small.empty?
303
+
304
+ # Merge the smallest cluster first
305
+ name, cluster = small.min_by { |_, c| c[:members].size }
306
+
307
+ # Find which other cluster this one connects to most
308
+ target = find_merge_target(cluster, clusters, name)
309
+
310
+ if target
311
+ clusters[target][:members].concat(cluster[:members])
312
+ cluster[:members].each { |id| clusters[target][:member_set].add(id) }
313
+ end
314
+
315
+ clusters.delete(name)
316
+ end
317
+ end
318
+
319
+ # Find the best cluster to merge into (most cross-cluster edges).
320
+ def find_merge_target(cluster, all_clusters, exclude_name)
321
+ connections = Hash.new(0)
322
+
323
+ cluster[:members].each do |id|
324
+ (@graph.dependencies_of(id) + @graph.dependents_of(id)).each do |connected|
325
+ all_clusters.each do |name, other|
326
+ next if name == exclude_name
327
+
328
+ connections[name] += 1 if other[:member_set].include?(connected)
329
+ end
330
+ end
331
+ end
332
+
333
+ return nil if connections.empty?
334
+
335
+ connections.max_by { |_, count| count }.first
336
+ end
337
+
338
+ # Enrich clusters with hub, entry points, boundary edges, and type breakdown.
339
+ def enrich_clusters(clusters, nodes, pagerank_scores)
340
+ clusters.each_value do |cluster|
341
+ members = cluster[:members]
342
+ member_set = cluster[:member_set]
343
+
344
+ # Hub: highest PageRank within the cluster
345
+ hub_id = members.max_by { |id| pagerank_scores[id] || 0 }
346
+ cluster[:hub] = hub_id
347
+
348
+ # Entry points: controllers and GraphQL resolvers in the cluster's dependents
349
+ entry_types = %w[controller graphql_resolver graphql_mutation graphql_query]
350
+ entry_points = Set.new
351
+ members.each do |id|
352
+ @graph.dependents_of(id).each do |dep|
353
+ meta = nodes[dep]
354
+ entry_points.add(dep) if meta && entry_types.include?(meta[:type].to_s)
355
+ end
356
+ end
357
+ cluster[:entry_points] = entry_points.to_a
358
+
359
+ # Boundary edges: connections that cross cluster boundaries
360
+ boundary = []
361
+ members.each do |id|
362
+ @graph.dependencies_of(id).each do |dep|
363
+ next if member_set.include?(dep)
364
+
365
+ dep_meta = nodes[dep]
366
+ next unless dep_meta
367
+
368
+ boundary << { from: id, to: dep, via: 'dependency' }
369
+ end
370
+
371
+ @graph.dependents_of(id).each do |dep|
372
+ next if member_set.include?(dep)
373
+
374
+ dep_meta = nodes[dep]
375
+ next unless dep_meta
376
+
377
+ boundary << { from: dep, to: id, via: 'dependent' }
378
+ end
379
+ end
380
+ # Deduplicate and limit boundary edges
381
+ cluster[:boundary_edges] = boundary.uniq { |e| [e[:from], e[:to]] }.first(20)
382
+
383
+ # Type breakdown
384
+ type_counts = members.each_with_object(Hash.new(0)) do |id, counts|
385
+ meta = nodes[id]
386
+ counts[meta[:type].to_s] += 1 if meta
387
+ end
388
+ cluster[:types] = type_counts
389
+
390
+ # Final shape
391
+ cluster[:member_count] = members.size
392
+ cluster.delete(:member_set) # Internal tracking, not part of output
393
+ end
394
+ end
395
+
185
396
  # ──────────────────────────────────────────────────────────────────────
186
397
  # Graph Accessors
187
398
  # ──────────────────────────────────────────────────────────────────────
@@ -165,6 +165,67 @@ module Woods
165
165
  lines.join("\n").rstrip
166
166
  end
167
167
 
168
+ # ── domain_clusters ────────────────────────────────────────
169
+
170
+ # @param data [Hash] Domain cluster data with :clusters and :total
171
+ # @return [String] Markdown domain cluster overview
172
+ def render_domain_clusters(data, **)
173
+ clusters = fetch_key(data, :clusters) || []
174
+ total = fetch_key(data, :total) || clusters.size
175
+ lines = []
176
+ lines << '## Domain Clusters'
177
+ lines << ''
178
+ lines << "#{total} domains detected."
179
+ lines << ''
180
+
181
+ clusters.each do |cluster|
182
+ name = cluster[:name] || cluster['name']
183
+ member_count = cluster[:member_count] || cluster['member_count'] || 0
184
+ hub = cluster[:hub] || cluster['hub']
185
+ lines << "### #{name} (#{member_count} units)"
186
+ lines << ''
187
+ lines << "**Hub:** #{hub}" if hub
188
+ lines << ''
189
+
190
+ # Type breakdown
191
+ types = cluster[:types] || cluster['types']
192
+ if types.is_a?(Hash) && types.any?
193
+ type_parts = types.sort_by { |_, count| -count }.map { |type, count| "#{count} #{type}s" }
194
+ lines << "**Types:** #{type_parts.join(', ')}"
195
+ end
196
+
197
+ # Entry points
198
+ entry_points = cluster[:entry_points] || cluster['entry_points'] || []
199
+ lines << "**Entry points:** #{entry_points.first(10).join(', ')}" if entry_points.any?
200
+
201
+ # Members (show first 15)
202
+ members = cluster[:members] || cluster['members'] || []
203
+ if members.any?
204
+ lines << ''
205
+ lines << '**Members:**'
206
+ members.first(15).each { |m| lines << "- #{m}" }
207
+ lines << "- _... and #{members.size - 15} more_" if members.size > 15
208
+ end
209
+
210
+ # Boundary edges (show first 10)
211
+ boundaries = cluster[:boundary_edges] || cluster['boundary_edges'] || []
212
+ if boundaries.any?
213
+ lines << ''
214
+ lines << '**Boundary connections:**'
215
+ boundaries.first(10).each do |edge|
216
+ from = edge[:from] || edge['from']
217
+ to = edge[:to] || edge['to']
218
+ via = edge[:via] || edge['via']
219
+ lines << "- #{from} → #{to} (#{via})"
220
+ end
221
+ end
222
+
223
+ lines << ''
224
+ end
225
+
226
+ lines.join("\n").rstrip
227
+ end
228
+
168
229
  # ── pagerank ────────────────────────────────────────────────
169
230
 
170
231
  # @param data [Hash] PageRank data with :total_nodes and :results
@@ -61,6 +61,7 @@ module Woods
61
61
  render_key: :dependents)
62
62
  define_structure_tool(server, reader, respond, renderer)
63
63
  define_graph_analysis_tool(server, reader, respond, renderer)
64
+ define_domain_clusters_tool(server, reader, respond, renderer)
64
65
  define_pagerank_tool(server, reader, respond, renderer)
65
66
  define_framework_tool(server, reader, respond, renderer)
66
67
  define_recent_changes_tool(server, reader, respond, renderer)
@@ -306,6 +307,39 @@ module Woods
306
307
  end
307
308
  end
308
309
 
310
+ def define_domain_clusters_tool(server, reader, respond, renderer)
311
+ coerce = method(:coerce_array)
312
+ coerce_int = method(:coerce_integer)
313
+ server.define_tool(
314
+ name: 'domain_clusters',
315
+ description: 'Group code units into semantic domains by namespace and graph connectivity. ' \
316
+ 'Returns clusters with hub nodes, entry points, boundary edges, and type breakdowns. ' \
317
+ 'Useful for understanding architectural domains and blast radius.',
318
+ input_schema: {
319
+ properties: {
320
+ min_size: {
321
+ type: 'integer',
322
+ description: 'Minimum units per cluster before merging into neighbors (default: 3)'
323
+ },
324
+ types: {
325
+ type: 'array', items: { type: 'string' },
326
+ description: 'Filter to these unit types (default: all). Example: ["model", "service", "job"]'
327
+ }
328
+ }
329
+ }
330
+ ) do |server_context:, min_size: nil, types: nil|
331
+ min_size = coerce_int.call(min_size) || 3
332
+ types = coerce.call(types)
333
+
334
+ graph = reader.dependency_graph
335
+ analyzer = Woods::GraphAnalyzer.new(graph)
336
+
337
+ clusters = analyzer.domain_clusters(min_size: min_size, types: types)
338
+
339
+ respond.call(renderer.render(:domain_clusters, { clusters: clusters, total: clusters.size }))
340
+ end
341
+ end
342
+
309
343
  def define_pagerank_tool(server, reader, respond, renderer)
310
344
  coerce = method(:coerce_array)
311
345
  coerce_int = method(:coerce_integer)
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+ require_relative 'rate_limiter'
7
+
8
+ module Woods
9
+ module Unblocked
10
+ # REST client for the Unblocked API v1.
11
+ #
12
+ # Handles document and collection CRUD with rate limiting, retries,
13
+ # and error handling. Uses Net::HTTP for zero external dependencies.
14
+ #
15
+ # @example
16
+ # client = Client.new(api_token: "ubk_...")
17
+ # client.put_document(
18
+ # collection_id: "uuid",
19
+ # title: "Order (model)",
20
+ # body: "# Order\n...",
21
+ # uri: "https://github.com/org/repo/blob/main/app/models/order.rb"
22
+ # )
23
+ #
24
+ class Client
25
+ BASE_URL = 'https://getunblocked.com/api/v1'
26
+ MAX_RETRIES = 3
27
+ DEFAULT_TIMEOUT = 30
28
+
29
+ # @param api_token [String] Unblocked API token (Personal or Team)
30
+ # @param rate_limiter [RateLimiter] Rate limiter instance
31
+ # @raise [ArgumentError] if api_token is nil or empty
32
+ def initialize(api_token:, rate_limiter: RateLimiter.new)
33
+ raise ArgumentError, 'api_token is required' if api_token.nil? || api_token.to_s.strip.empty?
34
+
35
+ @api_token = api_token
36
+ @rate_limiter = rate_limiter
37
+ end
38
+
39
+ # Create or update a document (upsert by URI).
40
+ #
41
+ # Documents are unique by `uri` across the organization. If a document
42
+ # with the given URI exists, it is updated; otherwise it is created.
43
+ # Documents become available for queries within ~1 minute.
44
+ #
45
+ # @param collection_id [String] Target collection UUID
46
+ # @param title [String] Document title (plain text)
47
+ # @param body [String] Document body (Markdown preferred)
48
+ # @param uri [String] Source URL (used as unique identifier and citation link)
49
+ # @return [Hash] { "id" => "document-uuid" }
50
+ def put_document(collection_id:, title:, body:, uri:)
51
+ request(:put, 'documents', {
52
+ collectionId: collection_id,
53
+ title: title,
54
+ body: body,
55
+ uri: uri
56
+ })
57
+ end
58
+
59
+ # Create a new collection.
60
+ #
61
+ # @param name [String] Collection name (1-32 chars)
62
+ # @param description [String] Collection description (1-4096 chars)
63
+ # @param icon_url [String, nil] Optional icon URL
64
+ # @return [Hash] { "id" => "collection-uuid", "name" => "...", ... }
65
+ def create_collection(name:, description:, icon_url: nil)
66
+ body = { name: name, description: description }
67
+ body[:iconUrl] = icon_url if icon_url
68
+ request(:post, 'collections', body)
69
+ end
70
+
71
+ # List all collections.
72
+ #
73
+ # @return [Array<Hash>] Collection objects
74
+ def list_collections
75
+ result = request(:get, 'collections')
76
+ result['items'] || result['data'] || [result].flatten.compact
77
+ end
78
+
79
+ # Delete a document by ID.
80
+ #
81
+ # @param document_id [String] Document UUID
82
+ # @return [Hash] API response
83
+ def delete_document(document_id:)
84
+ request(:delete, "documents/#{document_id}")
85
+ end
86
+
87
+ private
88
+
89
+ def request(method, path, body = nil)
90
+ retries = 0
91
+
92
+ loop do
93
+ response = @rate_limiter.track { execute_http(method, path, body) }
94
+
95
+ return parse_response(response) if response.is_a?(Net::HTTPSuccess)
96
+
97
+ if response.code == '429' && retries < MAX_RETRIES
98
+ retries += 1
99
+ wait_time = (response['Retry-After'] || (retries * 2)).to_f
100
+ sleep(wait_time)
101
+ next
102
+ end
103
+
104
+ raise_api_error(response)
105
+ end
106
+ end
107
+
108
+ def execute_http(method, path, body)
109
+ attempts = 0
110
+ begin
111
+ uri = URI("#{BASE_URL}/#{path}")
112
+ http = Net::HTTP.new(uri.host, uri.port)
113
+ http.use_ssl = true
114
+ http.open_timeout = DEFAULT_TIMEOUT
115
+ http.read_timeout = DEFAULT_TIMEOUT
116
+
117
+ req = build_request(method, uri, body)
118
+ http.request(req)
119
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, Errno::ECONNREFUSED => e
120
+ attempts += 1
121
+ raise Woods::Error, "Network error after #{attempts} retries: #{e.message}" if attempts >= MAX_RETRIES
122
+
123
+ sleep(2**attempts)
124
+ retry
125
+ end
126
+ end
127
+
128
+ def build_request(method, uri, body)
129
+ req = case method
130
+ when :put then Net::HTTP::Put.new(uri)
131
+ when :post then Net::HTTP::Post.new(uri)
132
+ when :get then Net::HTTP::Get.new(uri)
133
+ when :delete then Net::HTTP::Delete.new(uri)
134
+ else raise ArgumentError, "Unsupported HTTP method: #{method}"
135
+ end
136
+
137
+ req['Authorization'] = "Bearer #{@api_token}"
138
+ req['Content-Type'] = 'application/json'
139
+ req.body = JSON.generate(body) if body
140
+
141
+ req
142
+ end
143
+
144
+ def parse_response(response)
145
+ return {} if response.body.nil? || response.body.strip.empty?
146
+
147
+ JSON.parse(response.body)
148
+ rescue JSON::ParserError
149
+ {}
150
+ end
151
+
152
+ def raise_api_error(response)
153
+ parsed = begin
154
+ JSON.parse(response.body)
155
+ rescue JSON::ParserError, TypeError
156
+ { 'message' => response.body&.slice(0, 200) || 'Unknown error' }
157
+ end
158
+ message = parsed['message'] || parsed['error'] || 'Unknown error'
159
+ raise Woods::Error, "Unblocked API error #{response.code}: #{message}"
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Unblocked
5
+ # Converts extracted unit JSON into condensed Markdown documents
6
+ # optimized for Unblocked's code review and Q&A context.
7
+ #
8
+ # Each unit type has a specialized formatting strategy that emphasizes
9
+ # what matters for code review: associations, blast radius, entry points,
10
+ # side effects, and structural complexity.
11
+ #
12
+ # @example
13
+ # builder = DocumentBuilder.new(repo_url: "https://github.com/bigcartel/admin")
14
+ # doc = builder.build(unit_data)
15
+ # # => { title: "Order (model)", body: "# Order (model)\n...", uri: "https://..." }
16
+ #
17
+ class DocumentBuilder
18
+ # @param repo_url [String] GitHub repo base URL for citation URIs
19
+ def initialize(repo_url:)
20
+ @repo_url = repo_url.chomp('/')
21
+ end
22
+
23
+ # Build a document hash from a unit's extracted data.
24
+ #
25
+ # @param unit_data [Hash] Parsed unit JSON (from IndexReader)
26
+ # @return [Hash] { title:, body:, uri: }
27
+ def build(unit_data)
28
+ type = unit_data['type']
29
+ identifier = unit_data['identifier']
30
+ file_path = unit_data['file_path']
31
+
32
+ {
33
+ title: "#{identifier} (#{type})",
34
+ body: build_body(unit_data),
35
+ uri: build_uri(file_path)
36
+ }
37
+ end
38
+
39
+ private
40
+
41
+ def build_uri(file_path)
42
+ return @repo_url unless file_path
43
+
44
+ "#{@repo_url}/blob/main/#{file_path}"
45
+ end
46
+
47
+ def build_body(unit_data)
48
+ type = unit_data['type']
49
+ case type
50
+ when 'model' then build_model_body(unit_data)
51
+ when 'controller' then build_controller_body(unit_data)
52
+ when 'service', 'job', 'mailer', 'manager', 'decorator', 'concern'
53
+ build_generic_body(unit_data)
54
+ when 'graphql', 'graphql_type', 'graphql_mutation', 'graphql_resolver', 'graphql_query'
55
+ build_graphql_body(unit_data)
56
+ else build_generic_body(unit_data)
57
+ end
58
+ end
59
+
60
+ # ── Model formatting ─────────────────────────────────────────────
61
+
62
+ def build_model_body(unit)
63
+ meta = unit['metadata'] || {}
64
+ sections = []
65
+
66
+ sections << model_header(unit, meta)
67
+ sections << model_associations(meta)
68
+ sections << model_dependents(unit)
69
+ sections << model_entry_points(unit)
70
+ sections << model_schema_highlights(meta)
71
+ sections << model_side_effects(unit)
72
+
73
+ sections.compact.join("\n\n")
74
+ end
75
+
76
+ def model_header(unit, meta)
77
+ parts = ["# #{unit['identifier']} (model)"]
78
+ file_info = ["**File:** `#{unit['file_path']}`"]
79
+ file_info << "**LOC:** #{meta['loc']}" if meta['loc']
80
+ file_info << "**Table:** #{meta['table_name']}" if meta['table_name']
81
+ column_count = meta['column_count'] || (meta['columns'] || []).size
82
+ file_info << "(#{column_count} columns)" if column_count&.positive?
83
+ parts << file_info.join(' | ')
84
+ parts.join("\n")
85
+ end
86
+
87
+ def model_associations(meta)
88
+ assocs = meta['associations'] || []
89
+ return nil if assocs.empty?
90
+
91
+ grouped = assocs.group_by { |a| a['type'] }
92
+ lines = ["## Associations (#{assocs.size})"]
93
+
94
+ %w[belongs_to has_many has_one has_and_belongs_to_many].each do |type|
95
+ items = grouped[type]
96
+ next unless items&.any?
97
+
98
+ targets = items.map do |a|
99
+ name = a['target'] || a['name']
100
+ dep = a.dig('options', 'dependent')
101
+ dep ? "#{name} (#{dep})" : name
102
+ end
103
+ lines << "**#{type}:** #{targets.join(', ')}"
104
+ end
105
+
106
+ lines.join("\n")
107
+ end
108
+
109
+ def model_dependents(unit)
110
+ deps = unit['dependents'] || []
111
+ return nil if deps.empty?
112
+
113
+ grouped = deps.group_by { |d| d['type'] }
114
+ summary_parts = grouped.map { |type, items| "#{items.size} #{type}s" }
115
+
116
+ lines = ["## Dependents (#{deps.size} units)"]
117
+ lines << summary_parts.join(', ')
118
+
119
+ # Blast radius assessment
120
+ if deps.size > 50
121
+ lines << '**High blast radius** — changes here affect many parts of the codebase'
122
+ elsif deps.size > 20
123
+ lines << '**Moderate blast radius** — changes may ripple to dependent code'
124
+ end
125
+
126
+ lines.join("\n")
127
+ end
128
+
129
+ def model_entry_points(unit)
130
+ deps = unit['dependents'] || []
131
+ controllers = deps.select { |d| d['type'] == 'controller' }
132
+ graphql = deps.select { |d| d['type']&.start_with?('graphql') }
133
+ jobs = deps.select { |d| d['type'] == 'job' }
134
+
135
+ return nil if controllers.empty? && graphql.empty?
136
+
137
+ lines = ['## Entry Points']
138
+ lines << "**Controllers:** #{controllers.map { |c| c['identifier'] }.join(', ')}" if controllers.any?
139
+ lines << "**GraphQL:** #{graphql.map { |g| g['identifier'] }.join(', ')}" if graphql.any?
140
+ lines << "**Jobs:** #{jobs.map { |j| j['identifier'] }.join(', ')}" if jobs.any?
141
+
142
+ lines.join("\n")
143
+ end
144
+
145
+ def model_schema_highlights(meta)
146
+ parts = []
147
+
148
+ enums = meta['enums']
149
+ if enums.is_a?(Hash) && enums.any?
150
+ enum_strs = enums.map { |name, values| "#{name} (#{format_enum_values(values)})" }
151
+ parts << "**Enums:** #{enum_strs.join('; ')}"
152
+ end
153
+
154
+ scopes = meta['scopes']
155
+ parts << "**Scopes:** #{scopes.map { |s| s['name'] }.join(', ')}" if scopes.is_a?(Array) && scopes.any?
156
+
157
+ concerns = meta['inlined_concerns']
158
+ parts << "**Concerns:** #{concerns.join(', ')}" if concerns.is_a?(Array) && concerns.any?
159
+
160
+ callbacks = meta['callbacks']
161
+ if callbacks.is_a?(Array) && callbacks.any?
162
+ parts << "**Callbacks (#{callbacks.size}):** #{format_callbacks(callbacks)}"
163
+ end
164
+
165
+ return nil if parts.empty?
166
+
167
+ (['## Schema Highlights'] + parts).join("\n")
168
+ end
169
+
170
+ def model_side_effects(unit)
171
+ deps = unit['dependents'] || []
172
+ jobs = deps.select { |d| d['type'] == 'job' }
173
+ mailers = deps.select { |d| d['type'] == 'mailer' }
174
+
175
+ return nil if jobs.empty? && mailers.empty?
176
+
177
+ lines = ['## Side Effects']
178
+ lines << "**Jobs:** #{jobs.map { |j| j['identifier'] }.join(', ')}" if jobs.any?
179
+ lines << "**Mailers:** #{mailers.map { |m| m['identifier'] }.join(', ')}" if mailers.any?
180
+
181
+ lines.join("\n")
182
+ end
183
+
184
+ # ── Controller formatting ────────────────────────────────────────
185
+
186
+ def build_controller_body(unit)
187
+ meta = unit['metadata'] || {}
188
+ sections = []
189
+
190
+ sections << "# #{unit['identifier']} (controller)"
191
+ sections << "**File:** `#{unit['file_path']}`"
192
+
193
+ ancestors = meta['ancestors']
194
+ sections << "**Inherits:** #{ancestors[1..3]&.join(' → ')}" if ancestors.is_a?(Array) && ancestors.size > 1
195
+
196
+ sections << controller_routes(meta)
197
+ sections << controller_dependencies(unit)
198
+ sections << controller_dependents(unit)
199
+
200
+ sections.compact.join("\n\n")
201
+ end
202
+
203
+ def controller_routes(meta)
204
+ routes = meta['routes']
205
+ return nil unless routes.is_a?(Hash) && routes.any?
206
+
207
+ lines = ['## Routes']
208
+ routes.each do |action, route_list|
209
+ next unless route_list.is_a?(Array)
210
+
211
+ route_list.each do |route|
212
+ next unless route.is_a?(Hash)
213
+
214
+ lines << "- `#{route['verb']} #{route['path']}` (#{action})"
215
+ end
216
+ end
217
+
218
+ lines.size > 1 ? lines.first(20).join("\n") : nil
219
+ end
220
+
221
+ def controller_dependencies(unit)
222
+ deps = unit['dependencies'] || []
223
+ return nil if deps.empty?
224
+
225
+ models = deps.select { |d| d['type'] == 'model' }.map { |d| d['target'] }
226
+ return nil if models.empty?
227
+
228
+ "## Dependencies\n**Models:** #{models.join(', ')}"
229
+ end
230
+
231
+ def controller_dependents(unit)
232
+ deps = unit['dependents'] || []
233
+ views = deps.select { |d| d['type'] == 'view_template' }
234
+ return nil if views.empty?
235
+
236
+ "## Views\n#{views.map { |v| "- `#{v['identifier']}`" }.first(10).join("\n")}"
237
+ end
238
+
239
+ # ── GraphQL formatting ───────────────────────────────────────────
240
+
241
+ def build_graphql_body(unit)
242
+ sections = []
243
+
244
+ sections << "# #{unit['identifier']} (#{unit['type']})"
245
+ sections << "**File:** `#{unit['file_path']}`"
246
+
247
+ deps = unit['dependencies'] || []
248
+ models = deps.select { |d| d['type'] == 'model' }.map { |d| d['target'] }
249
+ sections << "**Models:** #{models.join(', ')}" if models.any?
250
+
251
+ dependents = unit['dependents'] || []
252
+ sections << "**Referenced by:** #{dependents.size} units" if dependents.any?
253
+
254
+ sections.compact.join("\n\n")
255
+ end
256
+
257
+ # ── Generic formatting (services, jobs, mailers, etc.) ──────────
258
+
259
+ def build_generic_body(unit)
260
+ meta = unit['metadata'] || {}
261
+ sections = []
262
+
263
+ sections << "# #{unit['identifier']} (#{unit['type']})"
264
+ sections << "**File:** `#{unit['file_path']}`"
265
+ sections << "**LOC:** #{meta['loc']}" if meta['loc']
266
+
267
+ deps = unit['dependencies'] || []
268
+ if deps.any?
269
+ by_type = deps.group_by { |d| d['type'] }
270
+ dep_parts = by_type.map { |type, items| "#{type}: #{items.map { |d| d['target'] }.join(', ')}" }
271
+ sections << "## Dependencies\n#{dep_parts.join("\n")}"
272
+ end
273
+
274
+ dependents = unit['dependents'] || []
275
+ if dependents.any?
276
+ grouped = dependents.group_by { |d| d['type'] }
277
+ summary = grouped.map { |type, items| "#{items.size} #{type}s" }
278
+ sections << "## Dependents (#{dependents.size})\n#{summary.join(', ')}"
279
+ end
280
+
281
+ sections.compact.join("\n\n")
282
+ end
283
+
284
+ # ── Helpers ──────────────────────────────────────────────────────
285
+
286
+ def format_enum_values(values)
287
+ case values
288
+ when Hash then values.keys.first(5).join(', ')
289
+ when Array then values.first(5).join(', ')
290
+ else values.to_s
291
+ end
292
+ end
293
+
294
+ def format_callbacks(callbacks)
295
+ callbacks.first(5).map do |cb|
296
+ "#{cb['type']}: #{cb['filter']}"
297
+ end.join(', ')
298
+ end
299
+ end
300
+ end
301
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'woods'
4
+ require_relative 'client'
5
+ require_relative 'rate_limiter'
6
+ require_relative 'document_builder'
7
+
8
+ module Woods
9
+ module Unblocked
10
+ # Orchestrates syncing Woods extraction data to an Unblocked collection.
11
+ #
12
+ # Reads extraction output from disk via IndexReader, converts units to
13
+ # condensed Markdown documents, and pushes via the Unblocked Documents API.
14
+ # All syncs are idempotent — documents are upserted by URI.
15
+ #
16
+ # @example
17
+ # exporter = Exporter.new(index_dir: "tmp/woods")
18
+ # stats = exporter.sync_all
19
+ # # => { synced: 940, skipped: 5060, errors: [] }
20
+ #
21
+ class Exporter
22
+ MAX_ERRORS = 100
23
+
24
+ # Unit types to sync, in priority order.
25
+ # All units are synced for these types.
26
+ FULL_SYNC_TYPES = %w[
27
+ model controller service job mailer manager decorator concern serializer
28
+ graphql graphql_type graphql_mutation graphql_resolver graphql_query
29
+ ].freeze
30
+
31
+ # Unit types where only the most-connected units are synced.
32
+ # Each entry: [type, max_count]
33
+ PARTIAL_SYNC_TYPES = [
34
+ ['poro', 100],
35
+ ['lib', 50]
36
+ ].freeze
37
+
38
+ # @param index_dir [String] Path to extraction output directory
39
+ # @param config [Configuration] Woods configuration (default: global config)
40
+ # @param client [Client, nil] Unblocked API client (auto-created from config if nil)
41
+ # @param reader [Object, nil] IndexReader instance (auto-created if nil)
42
+ # @param output [IO] Progress output stream (default: $stdout)
43
+ # @raise [ConfigurationError] if required config is missing
44
+ def initialize(index_dir:, config: Woods.configuration, client: nil, reader: nil, output: $stdout)
45
+ @collection_id = config.unblocked_collection_id
46
+ raise ConfigurationError, 'unblocked_collection_id is required' unless @collection_id
47
+
48
+ repo_url = config.unblocked_repo_url
49
+ raise ConfigurationError, 'unblocked_repo_url is required' unless repo_url
50
+
51
+ api_token = config.unblocked_api_token
52
+ raise ConfigurationError, 'unblocked_api_token is required' unless api_token
53
+
54
+ budget = ENV.fetch('UNBLOCKED_DAILY_BUDGET', RateLimiter::DEFAULT_BUDGET).to_i
55
+ limiter = RateLimiter.new(daily_budget: budget)
56
+
57
+ @client = client || Client.new(api_token: api_token, rate_limiter: limiter)
58
+ @reader = reader || build_reader(index_dir)
59
+ @builder = DocumentBuilder.new(repo_url: repo_url)
60
+ @output = output
61
+ end
62
+
63
+ # Sync all configured unit types to the Unblocked collection.
64
+ #
65
+ # @return [Hash] { synced: Integer, skipped: Integer, errors: Array<String> }
66
+ def sync_all
67
+ synced = 0
68
+ skipped = 0
69
+ errors = []
70
+
71
+ FULL_SYNC_TYPES.each do |type|
72
+ result = sync_type(type)
73
+ synced += result[:synced]
74
+ skipped += result[:skipped]
75
+ errors.concat(result[:errors])
76
+ end
77
+
78
+ PARTIAL_SYNC_TYPES.each do |type, max_count|
79
+ result = sync_type_partial(type, max_count)
80
+ synced += result[:synced]
81
+ skipped += result[:skipped]
82
+ errors.concat(result[:errors])
83
+ end
84
+
85
+ { synced: synced, skipped: skipped, errors: cap_errors(errors) }
86
+ end
87
+
88
+ # Sync all units of a given type.
89
+ #
90
+ # @param type [String] Unit type (e.g. "model", "controller")
91
+ # @return [Hash] { synced: Integer, skipped: Integer, errors: Array<String> }
92
+ def sync_type(type)
93
+ units = @reader.list_units(type: type)
94
+ log " #{type}: #{units.size} units"
95
+
96
+ sync_units(units)
97
+ end
98
+
99
+ # Sync the top N most-connected units of a type (by dependent count).
100
+ #
101
+ # @param type [String] Unit type
102
+ # @param max_count [Integer] Maximum units to sync
103
+ # @return [Hash] { synced: Integer, skipped: Integer, errors: Array<String> }
104
+ def sync_type_partial(type, max_count)
105
+ units = @reader.list_units(type: type)
106
+ return empty_stats if units.empty?
107
+
108
+ # Load full data to sort by dependent count
109
+ units_with_data = units.filter_map do |entry|
110
+ data = @reader.find_unit(entry['identifier'])
111
+ next unless data
112
+
113
+ dep_count = (data['dependents'] || []).size
114
+ { entry: entry, data: data, dep_count: dep_count }
115
+ end
116
+
117
+ top_units = units_with_data.sort_by { |u| -u[:dep_count] }.first(max_count)
118
+ skipped_count = [units.size - max_count, 0].max
119
+
120
+ log " #{type}: #{top_units.size}/#{units.size} units (top by dependents)"
121
+
122
+ result = sync_unit_data(top_units.map { |u| [u[:entry], u[:data]] })
123
+ result[:skipped] += skipped_count
124
+ result
125
+ end
126
+
127
+ private
128
+
129
+ def sync_units(units)
130
+ synced = 0
131
+ skipped = 0
132
+ errors = []
133
+
134
+ units.each do |entry|
135
+ unit_data = @reader.find_unit(entry['identifier'])
136
+ unless unit_data
137
+ skipped += 1
138
+ next
139
+ end
140
+
141
+ push_document(unit_data)
142
+ synced += 1
143
+ rescue Woods::Error => e
144
+ errors << "#{entry['identifier']}: #{e.message}"
145
+ break if e.message.include?('daily budget exhausted')
146
+ rescue StandardError => e
147
+ errors << "#{entry['identifier']}: #{e.message}"
148
+ end
149
+
150
+ { synced: synced, skipped: skipped, errors: errors }
151
+ end
152
+
153
+ def sync_unit_data(entries_with_data)
154
+ synced = 0
155
+ skipped = 0
156
+ errors = []
157
+
158
+ entries_with_data.each do |entry, unit_data|
159
+ push_document(unit_data)
160
+ synced += 1
161
+ rescue Woods::Error => e
162
+ errors << "#{entry['identifier']}: #{e.message}"
163
+ break if e.message.include?('daily budget exhausted')
164
+ rescue StandardError => e
165
+ errors << "#{entry['identifier']}: #{e.message}"
166
+ end
167
+
168
+ { synced: synced, skipped: skipped, errors: errors }
169
+ end
170
+
171
+ def push_document(unit_data)
172
+ doc = @builder.build(unit_data)
173
+ @client.put_document(
174
+ collection_id: @collection_id,
175
+ title: doc[:title],
176
+ body: doc[:body],
177
+ uri: doc[:uri]
178
+ )
179
+ end
180
+
181
+ def build_reader(index_dir)
182
+ require_relative '../mcp/index_reader'
183
+ Woods::MCP::IndexReader.new(index_dir)
184
+ end
185
+
186
+ def empty_stats
187
+ { synced: 0, skipped: 0, errors: [] }
188
+ end
189
+
190
+ def cap_errors(errors)
191
+ return errors if errors.size <= MAX_ERRORS
192
+
193
+ errors.first(MAX_ERRORS) + ["... and #{errors.size - MAX_ERRORS} more errors"]
194
+ end
195
+
196
+ def log(message)
197
+ @output&.puts(message)
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Unblocked
5
+ # Daily budget-based rate limiter for the Unblocked API (1000 calls/day).
6
+ #
7
+ # Unlike Notion's per-second throttling, Unblocked limits by daily call count.
8
+ # Tracks usage against a configurable budget, warns when approaching the limit,
9
+ # and raises when exhausted.
10
+ #
11
+ # @example
12
+ # limiter = RateLimiter.new(daily_budget: 1000)
13
+ # limiter.track { client.put_document(...) } # => result
14
+ # limiter.remaining # => 999
15
+ #
16
+ class RateLimiter
17
+ DEFAULT_BUDGET = 1000
18
+ WARN_THRESHOLD = 0.8 # Warn at 80% usage
19
+
20
+ # @param daily_budget [Integer] Maximum API calls per day
21
+ # @param warn_io [IO] Where to write warnings (default: $stderr)
22
+ def initialize(daily_budget: DEFAULT_BUDGET, warn_io: $stderr)
23
+ unless daily_budget.is_a?(Integer) && daily_budget.positive?
24
+ raise ArgumentError, 'daily_budget must be positive'
25
+ end
26
+
27
+ @daily_budget = daily_budget
28
+ @calls_today = 0
29
+ @warn_io = warn_io
30
+ @warned = false
31
+ @mutex = Mutex.new
32
+ end
33
+
34
+ # Execute a block, tracking the API call against the daily budget.
35
+ #
36
+ # @yield The API call to execute
37
+ # @return [Object] The block's return value
38
+ # @raise [Woods::Error] if daily budget is exhausted
39
+ def track
40
+ raise ArgumentError, 'block required' unless block_given?
41
+
42
+ @mutex.synchronize do
43
+ if @calls_today >= @daily_budget
44
+ raise Woods::Error,
45
+ "Unblocked API daily budget exhausted (#{@daily_budget} calls). " \
46
+ 'Budget resets at midnight PST. Use UNBLOCKED_DAILY_BUDGET to adjust.'
47
+ end
48
+
49
+ @calls_today += 1
50
+ warn_if_approaching_limit
51
+ end
52
+
53
+ yield
54
+ end
55
+
56
+ # Number of API calls remaining in the daily budget.
57
+ #
58
+ # @return [Integer]
59
+ def remaining
60
+ @daily_budget - @calls_today
61
+ end
62
+
63
+ # Number of API calls used today.
64
+ #
65
+ # @return [Integer]
66
+ def used
67
+ @calls_today
68
+ end
69
+
70
+ # Reset the daily counter (for testing or manual reset).
71
+ #
72
+ # @return [void]
73
+ def reset!
74
+ @mutex.synchronize do
75
+ @calls_today = 0
76
+ @warned = false
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def warn_if_approaching_limit
83
+ return if @warned
84
+ return unless @calls_today >= (@daily_budget * WARN_THRESHOLD).to_i
85
+
86
+ @warned = true
87
+ @warn_io&.puts(
88
+ "WARNING: Unblocked API usage at #{@calls_today}/#{@daily_budget} " \
89
+ "(#{remaining} calls remaining)"
90
+ )
91
+ end
92
+ end
93
+ end
94
+ end
data/lib/woods/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Woods
4
- VERSION = '1.1.0'
4
+ VERSION = '1.2.0'
5
5
  end
data/lib/woods.rb CHANGED
@@ -43,6 +43,7 @@ module Woods
43
43
  :session_tracer_enabled, :session_store, :session_id_proc, :session_exclude_paths,
44
44
  :console_mcp_enabled, :console_mcp_path, :console_redacted_columns,
45
45
  :notion_api_token, :notion_database_ids,
46
+ :unblocked_api_token, :unblocked_collection_id, :unblocked_repo_url,
46
47
  :cache_store, :cache_options
47
48
  attr_reader :max_context_tokens, :similarity_threshold, :extractors, :pretty_json, :context_format,
48
49
  :cache_enabled
@@ -70,6 +71,9 @@ module Woods
70
71
  @console_redacted_columns = []
71
72
  @notion_api_token = nil
72
73
  @notion_database_ids = {}
74
+ @unblocked_api_token = nil
75
+ @unblocked_collection_id = nil
76
+ @unblocked_repo_url = nil
73
77
  @cache_enabled = false
74
78
  @cache_store = nil # :redis, :solid_cache, :memory, or a CacheStore instance
75
79
  @cache_options = {} # { redis: client, cache: store, ttl: { embeddings: 86400, ... } }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: woods
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leah Armstrong
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-03-15 00:00:00.000000000 Z
11
+ date: 2026-03-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: mcp
@@ -237,6 +237,10 @@ files:
237
237
  - lib/woods/temporal/json_snapshot_store.rb
238
238
  - lib/woods/temporal/snapshot_store.rb
239
239
  - lib/woods/token_utils.rb
240
+ - lib/woods/unblocked/client.rb
241
+ - lib/woods/unblocked/document_builder.rb
242
+ - lib/woods/unblocked/exporter.rb
243
+ - lib/woods/unblocked/rate_limiter.rb
240
244
  - lib/woods/version.rb
241
245
  homepage: https://github.com/lost-in-the/woods
242
246
  licenses: