woods 1.6.1 → 2.0.0.beta2
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 +2035 -0
- data/CONTRIBUTING.md +253 -87
- data/README.md +161 -513
- data/SECURITY.md +92 -0
- data/assets/woods-wordmark-white-with-bg.png +0 -0
- data/docs/AGENT_GUIDE.md +204 -0
- data/docs/AGENT_SETUP.md +205 -0
- data/docs/BACKEND_MATRIX.md +470 -0
- data/docs/CONFIGURATION_REFERENCE.md +655 -0
- data/docs/CONSOLE_MCP_SETUP.md +829 -0
- data/docs/DOCKER_SETUP.md +454 -0
- data/docs/EMBEDDING_MODELS.md +136 -0
- data/docs/EVALUATION.md +91 -0
- data/docs/EXTRACTOR_REFERENCE.md +765 -0
- data/docs/FAQ.md +544 -0
- data/docs/GETTING_STARTED.md +183 -0
- data/docs/INCREMENTAL_EXTRACTION.md +455 -0
- data/docs/INTERNALS.md +418 -0
- data/docs/MCP_HTTP_TRANSPORT.md +144 -0
- data/docs/MCP_SERVERS.md +231 -0
- data/docs/MCP_TOOL_COOKBOOK.md +987 -0
- data/docs/MCP_WORKTREE_SETUP.md +127 -0
- data/docs/NOTION_INTEGRATION.md +283 -0
- data/docs/OBSIDIAN_INTEGRATION.md +170 -0
- data/docs/PUBLISHED_INDEX.md +213 -0
- data/docs/README.md +94 -0
- data/docs/RETRIEVAL_GUIDE.md +267 -0
- data/docs/TOKEN_BENCHMARK.md +68 -0
- data/docs/TROUBLESHOOTING.md +841 -0
- data/docs/UNBLOCKED_INTEGRATION.md +279 -0
- data/docs/UPGRADING_TO_2.md +321 -0
- data/docs/WATCH_DAEMON.md +667 -0
- data/docs/WHY_WOODS.md +219 -0
- data/exe/woods-console +40 -4
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +80 -11
- data/exe/woods-mcp-start +57 -52
- data/lib/generators/woods/install_generator.rb +6 -5
- data/lib/generators/woods/pgvector_generator.rb +6 -3
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
- data/lib/generators/woods/templates/woods.rb.tt +49 -28
- data/lib/tasks/woods.rake +622 -168
- data/lib/tasks/woods_checks.rake +107 -0
- data/lib/tasks/woods_evaluation.rake +164 -80
- data/lib/woods/ast/call_site_extractor.rb +6 -15
- data/lib/woods/ast/method_extractor.rb +19 -9
- data/lib/woods/ast/parser.rb +54 -8
- data/lib/woods/atomic_file.rb +171 -2
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +7 -2
- data/lib/woods/cache/cache_store.rb +9 -1
- data/lib/woods/cache/solid_cache_store.rb +6 -4
- data/lib/woods/change_set.rb +88 -0
- data/lib/woods/checks/generation_resolution.rb +34 -0
- data/lib/woods/checks/moved_messages.rb +186 -0
- data/lib/woods/chunking/semantic_chunker.rb +160 -18
- data/lib/woods/console/audit_logger.rb +12 -3
- data/lib/woods/console/bridge_protocol.rb +3 -16
- data/lib/woods/console/connection_manager.rb +51 -136
- data/lib/woods/console/dispatch_pipeline.rb +42 -12
- data/lib/woods/console/embedded_executor.rb +806 -149
- data/lib/woods/console/eval_guard.rb +27 -20
- data/lib/woods/console/input_contract.rb +78 -0
- data/lib/woods/console/model_validator.rb +29 -1
- data/lib/woods/console/rack_middleware.rb +65 -42
- data/lib/woods/console/redactor.rb +26 -8
- data/lib/woods/console/safe_context.rb +58 -10
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +119 -247
- data/lib/woods/console/sql_noise_stripper.rb +125 -16
- data/lib/woods/console/sql_table_scanner.rb +82 -22
- data/lib/woods/console/sql_validator.rb +459 -29
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +463 -90
- data/lib/woods/console/tools/tier1.rb +1 -5
- data/lib/woods/console/tools/tier4.rb +18 -9
- data/lib/woods/coordination/lock_heartbeat.rb +103 -0
- data/lib/woods/coordination/pipeline_lock.rb +263 -53
- data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
- data/lib/woods/db/migrator.rb +3 -9
- data/lib/woods/db/schema_version.rb +47 -2
- data/lib/woods/dependency_graph.rb +898 -64
- data/lib/woods/embedding/fake.rb +138 -0
- data/lib/woods/embedding/indexer.rb +832 -40
- data/lib/woods/embedding/openai.rb +77 -19
- data/lib/woods/embedding/provider.rb +189 -11
- data/lib/woods/embedding/text_preparer.rb +1 -1
- data/lib/woods/embedding/token_counter.rb +0 -7
- data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
- data/lib/woods/evaluation/ablation_executor.rb +67 -0
- data/lib/woods/evaluation/ablation_provenance.rb +38 -0
- data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
- data/lib/woods/evaluation/ablation_runner.rb +173 -0
- data/lib/woods/evaluation/ablation_summary.rb +65 -0
- data/lib/woods/evaluation/ablation_task.rb +66 -0
- data/lib/woods/evaluation/ablation_task_set.rb +77 -0
- data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
- data/lib/woods/evaluation/ablation_worktree.rb +71 -0
- data/lib/woods/evaluation/baseline.rb +60 -0
- data/lib/woods/evaluation/baseline_runner.rb +11 -3
- data/lib/woods/evaluation/evaluator.rb +41 -8
- data/lib/woods/evaluation/query_set.rb +79 -13
- data/lib/woods/evaluation/report_generator.rb +20 -1
- data/lib/woods/export/unit_facts.rb +0 -11
- data/lib/woods/extracted_unit.rb +22 -63
- data/lib/woods/extractor.rb +2783 -238
- data/lib/woods/extractors/action_cable_extractor.rb +9 -4
- data/lib/woods/extractors/ast_source_extraction.rb +20 -2
- data/lib/woods/extractors/caching_extractor.rb +46 -12
- data/lib/woods/extractors/callback_analyzer.rb +39 -9
- data/lib/woods/extractors/component_discovery.rb +123 -0
- data/lib/woods/extractors/concern_extractor.rb +17 -3
- data/lib/woods/extractors/controller_extractor.rb +389 -29
- data/lib/woods/extractors/decorator_extractor.rb +7 -14
- data/lib/woods/extractors/engine_extractor.rb +53 -8
- data/lib/woods/extractors/event_extractor.rb +55 -4
- data/lib/woods/extractors/factory_extractor.rb +49 -11
- data/lib/woods/extractors/graphql_extractor.rb +162 -66
- data/lib/woods/extractors/i18n_extractor.rb +6 -1
- data/lib/woods/extractors/job_extractor.rb +51 -21
- data/lib/woods/extractors/lib_extractor.rb +23 -17
- data/lib/woods/extractors/line_neutralizer.rb +171 -0
- data/lib/woods/extractors/mailer_extractor.rb +9 -1
- data/lib/woods/extractors/manager_extractor.rb +19 -2
- data/lib/woods/extractors/migration_extractor.rb +22 -11
- data/lib/woods/extractors/model_extractor.rb +292 -57
- data/lib/woods/extractors/package_extractor.rb +154 -0
- data/lib/woods/extractors/phlex_extractor.rb +18 -3
- data/lib/woods/extractors/policy_extractor.rb +6 -5
- data/lib/woods/extractors/poro_extractor.rb +13 -14
- data/lib/woods/extractors/pundit_extractor.rb +3 -3
- data/lib/woods/extractors/rails_source_extractor.rb +24 -7
- data/lib/woods/extractors/rake_task_extractor.rb +158 -30
- data/lib/woods/extractors/reference_patterns.rb +38 -0
- data/lib/woods/extractors/route_extractor.rb +58 -2
- data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
- data/lib/woods/extractors/serializer_extractor.rb +3 -4
- data/lib/woods/extractors/service_extractor.rb +11 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
- data/lib/woods/extractors/shared_utility_methods.rb +36 -6
- data/lib/woods/extractors/source_nesting.rb +560 -0
- data/lib/woods/extractors/state_machine_extractor.rb +30 -18
- data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
- data/lib/woods/extractors/view_component_extractor.rb +28 -3
- data/lib/woods/extractors/view_engines/erb.rb +17 -3
- data/lib/woods/feedback/gap_detector.rb +9 -3
- data/lib/woods/feedback/store.rb +7 -1
- data/lib/woods/filename_utils.rb +29 -1
- data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
- data/lib/woods/flow_assembler.rb +147 -26
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +175 -22
- data/lib/woods/gem_mapper.rb +285 -0
- data/lib/woods/generation.rb +185 -0
- data/lib/woods/git_command.rb +38 -0
- data/lib/woods/git_provenance.rb +16 -2
- data/lib/woods/graph_analyzer.rb +564 -87
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +102 -13
- data/lib/woods/mcp/bootstrap_state.rb +77 -0
- data/lib/woods/mcp/bootstrapper.rb +582 -77
- data/lib/woods/mcp/config_resolver.rb +66 -6
- data/lib/woods/mcp/errors.rb +60 -0
- data/lib/woods/mcp/index_reader.rb +836 -117
- data/lib/woods/mcp/index_reader_pinning.rb +78 -0
- data/lib/woods/mcp/origin_guard.rb +66 -7
- data/lib/woods/mcp/protocol_policy.rb +98 -0
- data/lib/woods/mcp/provider_probe.rb +45 -6
- data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
- data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
- data/lib/woods/mcp/server.rb +898 -152
- data/lib/woods/mcp/tasks/extension.rb +196 -0
- data/lib/woods/mcp/tasks/request_capture.rb +45 -0
- data/lib/woods/mcp/tasks/store.rb +518 -0
- data/lib/woods/mcp/tool_contract.rb +171 -0
- data/lib/woods/mcp/tool_response_renderer.rb +7 -0
- data/lib/woods/model_name_cache.rb +19 -1
- data/lib/woods/notion/client.rb +132 -36
- data/lib/woods/notion/exporter.rb +456 -61
- data/lib/woods/notion/mappers/column_mapper.rb +34 -5
- data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
- data/lib/woods/notion/mappers/model_mapper.rb +21 -6
- data/lib/woods/notion/mappers/shared.rb +45 -3
- data/lib/woods/notion/sync_manifest.rb +258 -0
- data/lib/woods/obsidian/errors.rb +6 -0
- data/lib/woods/obsidian/name_mapper.rb +40 -24
- data/lib/woods/obsidian/vault_exporter.rb +103 -36
- data/lib/woods/operator/pipeline_guard.rb +118 -21
- data/lib/woods/operator/status_reporter.rb +20 -3
- data/lib/woods/path_dispatcher.rb +276 -0
- data/lib/woods/payload_store.rb +236 -0
- data/lib/woods/published_index/edge_shaper.rb +61 -0
- data/lib/woods/published_index/generation_catalog.rb +72 -0
- data/lib/woods/published_index/typed_unit_reader.rb +48 -0
- data/lib/woods/published_index.rb +287 -0
- data/lib/woods/railtie.rb +69 -30
- data/lib/woods/railtie_support.rb +167 -0
- data/lib/woods/release.rb +12 -0
- data/lib/woods/reload_policy.rb +206 -0
- data/lib/woods/resilience/circuit_breaker.rb +47 -8
- data/lib/woods/resilience/index_validator.rb +296 -10
- data/lib/woods/resilience/retryable_provider.rb +71 -6
- data/lib/woods/resolved_config.rb +55 -11
- data/lib/woods/retrieval/context_assembler.rb +132 -40
- data/lib/woods/retrieval/query_classifier.rb +26 -8
- data/lib/woods/retrieval/ranker.rb +193 -28
- data/lib/woods/retrieval/search_executor.rb +206 -39
- data/lib/woods/retriever.rb +317 -71
- data/lib/woods/retry_after.rb +22 -2
- data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
- data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
- data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
- data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
- data/lib/woods/ruby_analyzer.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/middleware.rb +1 -2
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
- data/lib/woods/session_tracer/session_flow_document.rb +56 -14
- data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
- data/lib/woods/session_tracer/store.rb +14 -1
- data/lib/woods/storage/metadata_store.rb +230 -26
- data/lib/woods/storage/pgvector.rb +180 -22
- data/lib/woods/storage/qdrant.rb +367 -41
- data/lib/woods/storage/snapshotter/metadata.rb +79 -16
- data/lib/woods/storage/snapshotter/vector.rb +128 -17
- data/lib/woods/storage/snapshotter.rb +23 -5
- data/lib/woods/storage/vector_store.rb +49 -8
- data/lib/woods/storage_identity.rb +28 -0
- data/lib/woods/tasks.rb +53 -2
- data/lib/woods/temporal/json_snapshot_store.rb +112 -42
- data/lib/woods/temporal/snapshot_store.rb +139 -42
- data/lib/woods/unblocked/client.rb +119 -17
- data/lib/woods/unblocked/document_builder.rb +34 -2
- data/lib/woods/unblocked/exporter.rb +63 -27
- data/lib/woods/unblocked/rate_limiter.rb +23 -9
- data/lib/woods/unblocked/sync_manifest.rb +16 -8
- data/lib/woods/update_check.rb +24 -1
- data/lib/woods/util/uuid5.rb +124 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +1345 -0
- data/lib/woods/watch/listen_watcher.rb +81 -0
- data/lib/woods/watch/polling_watcher.rb +137 -0
- data/lib/woods/watch/status.rb +169 -0
- data/lib/woods/watch/tree_scan.rb +163 -0
- data/lib/woods/watch/watcher.rb +100 -0
- data/lib/woods.rb +138 -9
- data/plugin/.claude-plugin/plugin.json +18 -0
- data/plugin/hooks/hooks.json +29 -0
- data/plugin/hooks/woods-post-edit.sh +226 -0
- data/plugin/hooks/woods-session-start.sh +77 -0
- data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
- data/plugin/skills/woods-diagnose/SKILL.md +75 -0
- data/plugin/skills/woods-investigate/SKILL.md +39 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
- data/plugin/skills/woods-setup/SKILL.md +99 -0
- metadata +134 -23
- data/lib/woods/console/adapters/cache_adapter.rb +0 -58
- data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
- data/lib/woods/console/adapters/job_adapter.rb +0 -74
- data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
- data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
- data/lib/woods/console/bridge.rb +0 -210
- data/lib/woods/formatting/claude_adapter.rb +0 -98
- data/lib/woods/formatting/generic_adapter.rb +0 -56
- data/lib/woods/formatting/gpt_adapter.rb +0 -64
- data/lib/woods/notion/mapper.rb +0 -40
- data/lib/woods/observability/health_check.rb +0 -79
- data/lib/woods/observability/instrumentation.rb +0 -34
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# Reading a published index from Ruby
|
|
2
|
+
|
|
3
|
+
`Woods::PublishedIndex` is the stable, read-only API for tools that are not MCP clients: RuboCop cops, CI gate scripts, and the `woods:check:*` tasks. It needs no Rails, opens one published generation, and never moves off it for the life of the reader.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
require 'woods/published_index'
|
|
7
|
+
|
|
8
|
+
index = Woods::PublishedIndex.new(Rails.root.join('tmp/woods'))
|
|
9
|
+
index.generation_number # => 42
|
|
10
|
+
index.unit('Order') # => { "type" => "model", "identifier" => "Order", ... }
|
|
11
|
+
index.units(type: 'model') # => index entries with a "type" key
|
|
12
|
+
index.edges(via: 'belongs_to') # => [{ from: "Comment", to: "Post", via: "belongs_to", through: nil, through_db: nil, disable_joins: false }]
|
|
13
|
+
index.dependents_of('Post') # => ["Comment", "PostsController"]
|
|
14
|
+
index.table_database_map # => { "orders" => "primary", "events" => "analytics" }
|
|
15
|
+
index.external_dependency_checksum # => "9f2c81ad..." (changes on every publish)
|
|
16
|
+
index.close
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Prefer the block form so the lock in the next section is always released:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
Woods::PublishedIndex.open(Rails.root.join('tmp/woods')) do |index|
|
|
23
|
+
index.table_database_map
|
|
24
|
+
end
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## One generation, pinned for the reader's whole life
|
|
28
|
+
|
|
29
|
+
Unlike `Woods::MCP::IndexReader`, a `PublishedIndex` never refreshes between calls. It resolves one generation at `.new`/`.open` time and every fact it returns, `units`, the table map, `generation_number`, `external_dependency_checksum`, comes from that one generation for as long as the reader is open. There is no `reload` and no auto-refresh: open a new reader to see a later publish.
|
|
30
|
+
|
|
31
|
+
Opening a numbered generation acquires that generation's `manifest.json` lock through the same retention protocol `Woods::PayloadStore#prune` respects (a shared advisory `flock`, taken read-only). The lock is held for the reader's whole lifetime, not just one read, so a publish's retention pass cannot remove the payload out from under a long-lived reader: `PayloadStore#prune` takes a non-blocking exclusive lock on the same file before removing a generation, and skips any generation it cannot lock. `#close` releases it (`ensure` in the block form), so a reader that is done reading stops holding the generation open.
|
|
32
|
+
|
|
33
|
+
**Not thread-safe.** A `PublishedIndex` instance is meant for one script or cop process reading one generation; it keeps no mutex around its lock file or its underlying `Woods::MCP::IndexReader`. Give each thread its own reader rather than sharing one.
|
|
34
|
+
|
|
35
|
+
## API
|
|
36
|
+
|
|
37
|
+
| Method | Returns | Notes |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `.new(index_dir, generation: nil)` | reader, holding a lock | `generation:` pins a published `payloads/gen-N`; omit for the currently published one |
|
|
40
|
+
| `.open(index_dir, generation: nil) { |index| ... }` | the block's value | releases the lock in `ensure`, on return or exception |
|
|
41
|
+
| `.available_generations(index_dir)` | `Array<Integer>` | Published generations, ascending: see below |
|
|
42
|
+
| `#generation_number` | Integer | 0 for an index written flat (pre-2.0 layout) |
|
|
43
|
+
| `#close` | nil | Releases the retention lock; safe to call more than once |
|
|
44
|
+
| `#unit(identifier, type: nil)` | Hash or nil | The unit JSON, string keys; see the collision note below |
|
|
45
|
+
| `#units(type: nil)` | `Array<Hash>` | `_index.json` entries plus `"type"` |
|
|
46
|
+
| `#edges(via: nil)`, `#each_edge` | `Array<Hash>` | Every forward edge with `through`, `through_db`, and `disable_joins`; an identifier shared by more than one type contributes one edge per owning type, never folded into a single deduplicated entry |
|
|
47
|
+
| `#dependents_of(identifier, via: nil)` | `Array<String>` | Reverse index |
|
|
48
|
+
| `#table_database_map` | `Hash` | Model table to database; empty on Rails 6.0 extractions |
|
|
49
|
+
| `#external_dependency_checksum` | String | SHA-256 of the pinned payload's `manifest.json` |
|
|
50
|
+
|
|
51
|
+
The reader wraps `Woods::MCP::IndexReader` with `auto_refresh: false`; the unit and graph shapes are the ones documented in [Extractor reference](EXTRACTOR_REFERENCE.md#extractedunit-field-reference).
|
|
52
|
+
|
|
53
|
+
### `#unit`: an identifier shared across types
|
|
54
|
+
|
|
55
|
+
`Woods::MCP::IndexReader#find_unit` keys its identifier map on identifier alone. If two type directories both list the same identifier (a model and a service both named `Foo`, for example), whichever type sorts last in `Woods::MCP::IndexReader::TYPE_DIRS` silently wins, and `unit(identifier)` returns that one. Pass `type:` to read a specific type's unit file directly and skip the collision entirely; `#table_database_map` always does this internally (`type: 'model'`), so a same-named non-model unit can never shadow a model's `table_name`/`database`.
|
|
56
|
+
|
|
57
|
+
### `available_generations`: published means published
|
|
58
|
+
|
|
59
|
+
A generation is listed only when both hold:
|
|
60
|
+
|
|
61
|
+
* its number is at or below the pointer `generation.json` currently names;
|
|
62
|
+
* its `payloads/gen-N` directory holds a `manifest.json`.
|
|
63
|
+
|
|
64
|
+
A directory numbered above the pointer (a payload built but never bumped to) and a directory missing its manifest (an interrupted or corrupted publish) are never listed, and `.new(index_dir, generation: N)` raises `ArgumentError` for either. Retention itself is bounded by `WOODS_PAYLOAD_RETENTION` (default 3).
|
|
65
|
+
|
|
66
|
+
A *missing* `generation.json` is not an error: it means a flat (pre-2.0) index, generation 0. A `generation.json` that **exists but will not parse** is different, a corrupt install, not an empty index, so `.available_generations` and `.new`/`.open` raise `Woods::PublishedIndex::CorruptPointerError` naming the file's path instead of silently reporting zero published generations.
|
|
67
|
+
|
|
68
|
+
### Durability: the pointer is the commit point
|
|
69
|
+
|
|
70
|
+
The pointer is not only what makes a generation *visible*, it is what makes it *durable*.
|
|
71
|
+
|
|
72
|
+
Payload files are written through `Woods::AtomicFile.write` with `durable: false`: a temp file, a chmod to the final mode, and a rename, but no `fsync`. Immediately before `generation.json` is written, the writer flushes the whole payload directory once with `Woods::AtomicFile.sync_directory_tree`, and then writes the pointer durably.
|
|
73
|
+
|
|
74
|
+
So the contract a reader can rely on is:
|
|
75
|
+
|
|
76
|
+
> When `generation.json` is durable, every file in the payload it names is durable.
|
|
77
|
+
|
|
78
|
+
What that gives up is an individual payload file being durable *before* the pointer exists. Nothing reads a payload file in that window: every reader resolves through the pointer, and a crash there leaves an unreferenced partial payload that the next run prunes. The saving is large, since two forced flushes per file is about 8.9ms each on btrfs, or 71s for 8000 units against 1s for one flush.
|
|
79
|
+
|
|
80
|
+
`sync_directory_tree` tries `syncfs(2)` through Fiddle, then `sync -f <dir>`, then a bare `sync`, then an `fsync` on every file in the tree. The last resort is what keeps this honest: the chain never silently does nothing, it only gets slower.
|
|
81
|
+
|
|
82
|
+
A file whose readers do **not** resolve through the pointer keeps its own `fsync`: `generation.json` itself, the watch daemon's `watch_status.json`, the update check cache, the Obsidian and Unblocked exports, Notion sync state, temporal snapshots, `checkpoint.json`, and MCP task records. Set `durable_payload_writes = true` to pay the per-file `fsync` on payload files as well; it cannot disable the publish flush.
|
|
83
|
+
|
|
84
|
+
## Keying a RuboCop cache on the index
|
|
85
|
+
|
|
86
|
+
RuboCop caches offenses per file and invalidates the cache when a cop's `external_dependency_checksum` changes. `rubocop-rails` uses this to re-run schema-aware cops when `db/schema.rb` changes. The same pattern works against Woods: the pinned payload's `manifest.json` is rewritten through `AtomicFile` on every publish, so its digest is the checksum.
|
|
87
|
+
|
|
88
|
+
### Worked example: `Multidb/ForeignKeyAcrossDatabases`
|
|
89
|
+
|
|
90
|
+
The cop flags an `add_foreign_key` in a migration whose two tables resolve to different databases. The table-to-database map comes from the index (`metadata.database` on model units), so the cop never boots Rails. A `:through` association's edge carries the same join rule under `through_db`: the through model can resolve to a database that differs from either end, so a check built on `#edges` rather than `#table_database_map` needs that value to catch the same class of cross-database join.
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
# lib/rubocop/cop/multidb/foreign_key_across_databases.rb
|
|
94
|
+
# frozen_string_literal: true
|
|
95
|
+
|
|
96
|
+
require 'woods/published_index'
|
|
97
|
+
|
|
98
|
+
module RuboCop
|
|
99
|
+
module Cop
|
|
100
|
+
module Multidb
|
|
101
|
+
# Flags `add_foreign_key :from, :to` when the two tables live in
|
|
102
|
+
# different databases. MySQL and PostgreSQL both refuse the constraint
|
|
103
|
+
# at the database level; catching it in review is cheaper.
|
|
104
|
+
class ForeignKeyAcrossDatabases < Base
|
|
105
|
+
MSG = 'Foreign key from `%<from>s` (%<from_db>s) to `%<to>s` (%<to_db>s) crosses databases.'
|
|
106
|
+
RESTRICT_ON_SEND = %i[add_foreign_key].freeze
|
|
107
|
+
|
|
108
|
+
def_node_matcher :foreign_key_tables, <<~PATTERN
|
|
109
|
+
(send nil? :add_foreign_key ${sym str} ${sym str} ...)
|
|
110
|
+
PATTERN
|
|
111
|
+
|
|
112
|
+
def on_send(node)
|
|
113
|
+
foreign_key_tables(node) do |from_node, to_node|
|
|
114
|
+
from = from_node.value.to_s
|
|
115
|
+
to = to_node.value.to_s
|
|
116
|
+
from_db = table_databases[from]
|
|
117
|
+
to_db = table_databases[to]
|
|
118
|
+
next if from_db.nil? || to_db.nil? || from_db == to_db
|
|
119
|
+
|
|
120
|
+
add_offense(node, message: format(MSG, from: from, from_db: from_db, to: to, to_db: to_db))
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# RuboCop re-runs the cop on every file when this changes, and the
|
|
125
|
+
# pinned manifest changes on every Woods publish.
|
|
126
|
+
def external_dependency_checksum
|
|
127
|
+
index.external_dependency_checksum
|
|
128
|
+
rescue ArgumentError
|
|
129
|
+
'no-woods-index'
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def table_databases
|
|
135
|
+
@table_databases ||= index.table_database_map
|
|
136
|
+
rescue ArgumentError
|
|
137
|
+
{}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def index
|
|
141
|
+
@index ||= Woods::PublishedIndex.new(File.join(Dir.pwd, 'tmp/woods'))
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Register it in `.rubocop.yml` with `require: ./lib/rubocop/cop/multidb/foreign_key_across_databases` and enable `Multidb/ForeignKeyAcrossDatabases` for `db/migrate/**/*.rb` and `db/*_migrate/**/*.rb`.
|
|
150
|
+
|
|
151
|
+
Keep the index current in CI so the cop sees the same generation the app runs on:
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
# config/ci.rb (Rails 8.1)
|
|
155
|
+
step "Woods: refresh", "bin/rails woods:incremental"
|
|
156
|
+
step "Style", "bin/rubocop"
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
A memoized cop instance holds its `PublishedIndex` (and its retention lock) for the process's lifetime; RuboCop runs each cop once per process, so there is no explicit `close` call above.
|
|
160
|
+
|
|
161
|
+
## Comparing two generations
|
|
162
|
+
|
|
163
|
+
`generation:` pins a retained payload. Two readers over two generations are the base for [`woods:check:moved_messages`](#moved-message-check), and for any check that asks "what changed between these two publishes".
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
before = Woods::PublishedIndex.new(dir, generation: 41)
|
|
167
|
+
after = Woods::PublishedIndex.new(dir, generation: 42)
|
|
168
|
+
added = after.units.map { |u| u['identifier'] } - before.units.map { |u| u['identifier'] }
|
|
169
|
+
before.close
|
|
170
|
+
after.close
|
|
171
|
+
added
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Retention is `WOODS_PAYLOAD_RETENTION` generations (default 3). Raise it on a CI runner that compares against an older baseline, or the older side of the comparison may no longer be published.
|
|
175
|
+
|
|
176
|
+
## Moved-message check
|
|
177
|
+
|
|
178
|
+
`woods:check:moved_messages` opens two retained generations through `Woods::PublishedIndex` (block form, so both readers' retention locks always release) and reports every public method name that looks like it moved from one unit to another while a `:test_coverage` edge did not follow.
|
|
179
|
+
|
|
180
|
+
Each row is a **candidate move into a unit without mapped tests**, never a proven coverage loss: the check matches on method name and kind alone, so two unrelated methods that happen to share both look identical to a real move. Reported only when the source unit was covered before and the destination is not covered after; a method that was never covered is not a regression this check owns. `WOODS_CHECK_STRICT=1` exits 1 on any finding, but the check stays heuristic either way, strict mode changes the exit code, not the confidence of a row.
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
bin/rails woods:check:moved_messages # previous retained generation vs the published one
|
|
184
|
+
bin/rails "woods:check:moved_messages[41,42]" # explicit generations
|
|
185
|
+
WOODS_CHECK_STRICT=1 bin/rails woods:check:moved_messages # exit 1 on findings, for CI (still heuristic)
|
|
186
|
+
WOODS_CHECK_JSON=1 bin/rails woods:check:moved_messages # also print findings as JSON
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
With no `[from,to]` given, the generations default to the latest two from `available_generations` (a pure function, `Woods::Checks::GenerationResolution`, picks them); an index with fewer than two retained generations exits 1 with a message naming `WOODS_PAYLOAD_RETENTION` as the fix. A `generation.json` that will not parse raises `Woods::PublishedIndex::CorruptPointerError`, named by the task before it propagates, rather than being swallowed as "no findings".
|
|
190
|
+
|
|
191
|
+
### Which key holds which method list
|
|
192
|
+
|
|
193
|
+
The check reads three metadata keys and normalizes them to one `[name, kind]` shape before matching, so an instance method never collides with a class method of the same bare name:
|
|
194
|
+
|
|
195
|
+
| Extractor family | Key | Shape |
|
|
196
|
+
|---|---|---|
|
|
197
|
+
| Services, POROs, managers, decorators, policies, validators, concerns, Pundit policies, lib units | `public_methods` | Regex-extracted; a class method is a bare name **prefixed with `self.`** (`"self.build"`), an instance method is bare (`"run"`) |
|
|
198
|
+
| Services, POROs, managers, decorators, policies, validators, concerns, lib units | `class_methods` | Regex-extracted `def self.foo` names, prefix already stripped |
|
|
199
|
+
| Concerns, models | `instance_methods` | Bare names; models get this from runtime introspection (`instance_methods(false)`, filtered), a wider list than the regex-based extractors produce |
|
|
200
|
+
| Phlex components, ViewComponents | `public_methods` | Runtime introspection (`public_instance_methods(false)`); always bare, since these extractors only ever report instance methods |
|
|
201
|
+
|
|
202
|
+
Model units carry both `instance_methods` and `class_methods` from runtime introspection, no `public_methods` key, and it is the widest of the method lists: every instance and class method the model responds to, not just the ones a source-regex can see.
|
|
203
|
+
|
|
204
|
+
| Situation | Reported |
|
|
205
|
+
|---|---|
|
|
206
|
+
| `total` left `Checkout` (covered) and appeared in `Pricing` (not covered) | yes |
|
|
207
|
+
| Same move, `Pricing` covered by a test_mapping unit | no |
|
|
208
|
+
| `total` was never covered before the move | no |
|
|
209
|
+
| Method renamed in place | no (nothing gained it) |
|
|
210
|
+
| An instance method and a same-named class method swap units | no (kind mismatch) |
|
|
211
|
+
| A `self.build` public method reappears as a `class_methods` entry named `build` | yes, `kind: :class` (normalized to the same signature) |
|
|
212
|
+
|
|
213
|
+
Temporal snapshots (`snapshot_diff`, `unit_history`) store content hashes only, so they cannot see a moved method; that is why the check reads payload generations. Keep at least two retained generations (`WOODS_PAYLOAD_RETENTION`, default 3).
|
data/docs/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Woods documentation
|
|
2
|
+
|
|
3
|
+
Woods extracts runtime-accurate Rails context and serves it to coding agents through MCP. Start with the task you are trying to complete; you do not need to read the documentation in order.
|
|
4
|
+
|
|
5
|
+
## What are you trying to do?
|
|
6
|
+
|
|
7
|
+
| Task | Start here | You will finish with |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| Install Woods in a Rails app | [Getting started](GETTING_STARTED.md) | A validated codebase index with the 14 packaged-default tools connected |
|
|
10
|
+
| Ask an agent to install or configure Woods | [Agent setup runbook](AGENT_SETUP.md) | A safe, reviewable install with an agent handoff report |
|
|
11
|
+
| Configure an MCP client or Docker path | [MCP servers](MCP_SERVERS.md) | A working Index Server and, if authorized, an optional Console Server |
|
|
12
|
+
| Use Woods tools as an agent | [Agent guide](AGENT_GUIDE.md) | A repeatable query workflow for code context, flows, and blast radius |
|
|
13
|
+
| Keep the index current automatically | [Watch daemon](WATCH_DAEMON.md) | A resident development process that catches up changes and republishes the index |
|
|
14
|
+
| Upgrade from Woods 1.x | [Upgrade to Woods 2.0](UPGRADING_TO_2.md) | A backed-up, re-indexed, verified v2 installation |
|
|
15
|
+
| Diagnose an error | [Troubleshooting](TROUBLESHOOTING.md) | Symptom-to-cause checks for extraction, MCP, embeddings, storage, and Docker |
|
|
16
|
+
| Contribute to Woods | [Contributing](../CONTRIBUTING.md) | A tested change with synchronized docs and plugin guidance |
|
|
17
|
+
|
|
18
|
+
Claude Code users can optionally install the packaged setup/configuration
|
|
19
|
+
workflows with `/plugin marketplace add lost-in-the/plugins`, followed by
|
|
20
|
+
`/plugin install woods-plugin@lost-in-the-plugins`. Woods and its MCP servers
|
|
21
|
+
do not otherwise depend on Claude Code; other clients use the same runbooks
|
|
22
|
+
and stdio or Streamable HTTP endpoints directly.
|
|
23
|
+
|
|
24
|
+
## First-time setup
|
|
25
|
+
|
|
26
|
+
- [Getting started](GETTING_STARTED.md): install, review generated configuration, remove the unused legacy application migration unless an older/custom integration requires it, extract, validate, and connect the Index Server.
|
|
27
|
+
- [Agent setup runbook](AGENT_SETUP.md): the same result with version checks, repository safety, approval boundaries, and a copyable agent prompt.
|
|
28
|
+
- [Docker setup](DOCKER_SETUP.md): extraction inside the container, container-first MCP launch, optional host launch, and path translation.
|
|
29
|
+
- [Configuration reference](CONFIGURATION_REFERENCE.md): every supported option, default, and environment variable.
|
|
30
|
+
- [Backend matrix](BACKEND_MATRIX.md): choose structural-only, local Ollama, pgvector/OpenAI, Qdrant/OpenAI, or shared-filesystem deployment.
|
|
31
|
+
|
|
32
|
+
## MCP and agents
|
|
33
|
+
|
|
34
|
+
- [MCP servers](MCP_SERVERS.md): choose the pre-extracted Index Server or live-data Console Server; configure clients; understand the callable 14 and 9/11 tool surfaces.
|
|
35
|
+
- [Agent guide](AGENT_GUIDE.md): start with `woods_status`, discover with `search`, inspect with `lookup`, and follow dependencies or flows.
|
|
36
|
+
- [MCP tool cookbook](MCP_TOOL_COOKBOOK.md): scenario-based calls with parameters and expected response shapes.
|
|
37
|
+
- [Console MCP setup](CONSOLE_MCP_SETUP.md): Console transports, blocked tables, credential scanning, redaction, SQL validation, and production safeguards.
|
|
38
|
+
- [MCP HTTP transport](MCP_HTTP_TRANSPORT.md): shared/remote Index Server transport, authentication, origins, and protocol details.
|
|
39
|
+
- [MCP worktree setup](MCP_WORKTREE_SETUP.md): register Woods correctly when agents work in linked git worktrees.
|
|
40
|
+
|
|
41
|
+
## Index lifecycle
|
|
42
|
+
|
|
43
|
+
- [Retrieval guide](RETRIEVAL_GUIDE.md): configure embeddings and understand semantic retrieval, ranking, and token budgets.
|
|
44
|
+
- [Embedding models](EMBEDDING_MODELS.md): choose and size local Ollama models.
|
|
45
|
+
- [Upgrade to Woods 2.0](UPGRADING_TO_2.md): identifier changes, atomic payloads, durable-store reconciliation, and rollback.
|
|
46
|
+
|
|
47
|
+
## Reference
|
|
48
|
+
|
|
49
|
+
- [Why Woods](WHY_WOODS.md): the problems runtime introspection solves.
|
|
50
|
+
- [Internals](INTERNALS.md): extraction, publication, graph, storage, retrieval, and MCP components.
|
|
51
|
+
- [Extractor reference](EXTRACTOR_REFERENCE.md): what each extractor produces and the edge cases it handles.
|
|
52
|
+
- [Reading a published index from Ruby](PUBLISHED_INDEX.md): the `Woods::PublishedIndex` Ruby API for cops, gate scripts, and `woods:check:*` tasks (including the moved-message check).
|
|
53
|
+
- [Evaluation](EVALUATION.md): retrieval scoring, baselines, and the agent-level index on/off ablation.
|
|
54
|
+
- [Backend matrix](BACKEND_MATRIX.md): implemented provider/store combinations and their operational requirements.
|
|
55
|
+
- [Token benchmark](TOKEN_BENCHMARK.md): evidence behind Woods token-estimation defaults.
|
|
56
|
+
- [FAQ](FAQ.md): short answers and links to the canonical guides.
|
|
57
|
+
|
|
58
|
+
## Exports and integrations
|
|
59
|
+
|
|
60
|
+
- [Notion integration](NOTION_INTEGRATION.md)
|
|
61
|
+
- [Obsidian integration](OBSIDIAN_INTEGRATION.md)
|
|
62
|
+
- [Unblocked integration](UNBLOCKED_INTEGRATION.md)
|
|
63
|
+
|
|
64
|
+
## Maintainer material
|
|
65
|
+
|
|
66
|
+
Historical build-phase documents are not user guides. Source checkouts also contain the [MCP protocol decision record](https://github.com/lost-in-the/woods/blob/main/docs/design/MCP_2026_STRATEGY.md), [generated self-analysis diagrams](https://github.com/lost-in-the/woods/tree/main/docs/self-analysis), and the maintainer work ledger in `backlog.json`; these maintainer-only paths are not packaged with the gem.
|
|
67
|
+
|
|
68
|
+
Contract records that read as maintainer reference rather than guides:
|
|
69
|
+
|
|
70
|
+
- [Incremental extraction](INCREMENTAL_EXTRACTION.md): the incremental/full equivalence contract and dispatch inventory.
|
|
71
|
+
- [Watch daemon](WATCH_DAEMON.md): the resident daemon's design contract (the user-facing entry point is the task table above).
|
|
72
|
+
|
|
73
|
+
The release flow, the version states `main` moves through, and the tag and dispatch runbook live in [CONTRIBUTING.md](../CONTRIBUTING.md#release-flow).
|
|
74
|
+
|
|
75
|
+
## Canonical owners
|
|
76
|
+
|
|
77
|
+
Use this map when changing behavior or documentation. Update the owner first; other pages should summarize and link instead of copying full instructions.
|
|
78
|
+
|
|
79
|
+
| Fact or workflow | Canonical owner |
|
|
80
|
+
|---|---|
|
|
81
|
+
| Install and first successful run | [GETTING_STARTED.md](GETTING_STARTED.md) |
|
|
82
|
+
| Agent-operated install/configuration | [AGENT_SETUP.md](AGENT_SETUP.md) |
|
|
83
|
+
| Configuration keys and defaults | [CONFIGURATION_REFERENCE.md](CONFIGURATION_REFERENCE.md) |
|
|
84
|
+
| MCP server setup and callable tool surface | [MCP_SERVERS.md](MCP_SERVERS.md) |
|
|
85
|
+
| Agent tool-selection workflow | [AGENT_GUIDE.md](AGENT_GUIDE.md) |
|
|
86
|
+
| Console security and transports | [CONSOLE_MCP_SETUP.md](CONSOLE_MCP_SETUP.md) |
|
|
87
|
+
| v1-to-v2 migration | [UPGRADING_TO_2.md](UPGRADING_TO_2.md) |
|
|
88
|
+
| Failure diagnosis | [TROUBLESHOOTING.md](TROUBLESHOOTING.md) |
|
|
89
|
+
| Contributor policy | [CONTRIBUTING.md](../CONTRIBUTING.md) |
|
|
90
|
+
| Coding-agent repository instructions | [AGENTS.md](https://github.com/lost-in-the/woods/blob/main/AGENTS.md) |
|
|
91
|
+
| Non-MCP Ruby access to a published index | [PUBLISHED_INDEX.md](PUBLISHED_INDEX.md) |
|
|
92
|
+
| Evaluation harnesses | [EVALUATION.md](EVALUATION.md) |
|
|
93
|
+
|
|
94
|
+
The current public surface is generated from 35 extractors. Counts and capability claims must match `.Codex/release-v2/surface-inventory.json`, which is generated from the code and verified in CI.
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# Retrieval Guide
|
|
2
|
+
|
|
3
|
+
Woods retrieval combines semantic search (vector similarity), keyword search (identifier/text matching), and graph traversal (dependency edges), fusing results with Reciprocal Rank Fusion (RRF) before assembling them into a token-budgeted context string. This is distinct from `search` (exact name/pattern lookup) or `lookup` (direct identifier fetch): retrieval is designed for natural-language questions about behavior, relationships, or concepts that span multiple code units.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## The Pipeline at a Glance
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
query
|
|
11
|
+
└─▶ QueryClassifier classify intent, scope, target type
|
|
12
|
+
└─▶ SearchExecutor select strategy, run parallel search
|
|
13
|
+
├── vector search (semantic similarity)
|
|
14
|
+
├── keyword search (identifier/text matching)
|
|
15
|
+
└── graph traversal (dependency edges)
|
|
16
|
+
└─▶ Ranker RRF fusion + weighted signal scoring
|
|
17
|
+
└─▶ ContextAssembler token-budgeted context string
|
|
18
|
+
└─▶ RetrievalResult
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
| Stage | Class | Responsibility |
|
|
22
|
+
|-------|-------|----------------|
|
|
23
|
+
| Classification | `Woods::Retrieval::QueryClassifier` | Detects intent, scope, target type, and framework context from the query text |
|
|
24
|
+
| Search | `Woods::Retrieval::SearchExecutor` | Maps classification to a strategy (`:vector`, `:keyword`, `:graph`, `:hybrid`, `:direct`) and executes it |
|
|
25
|
+
| Ranking | `Woods::Retrieval::Ranker` | Applies RRF across sources, then weighted signal scoring (semantic, keyword, recency, importance, type match, diversity) |
|
|
26
|
+
| Assembly | `Woods::Retrieval::ContextAssembler` | Fills a token budget with ranked units, sectioned into structural / primary / supporting / framework blocks |
|
|
27
|
+
| Orchestration | `Woods::Retriever` | Coordinates all four stages; returns a `RetrievalResult` with `context`, `sources`, `strategy`, `tokens_used`, and `trace` |
|
|
28
|
+
|
|
29
|
+
### Search strategies
|
|
30
|
+
|
|
31
|
+
`SearchExecutor` selects one of five strategies based on query classification:
|
|
32
|
+
|
|
33
|
+
| Strategy | When selected | What it does |
|
|
34
|
+
|----------|--------------|-------------|
|
|
35
|
+
| `:vector` | `understand`, `debug`, `implement` intents | Embeds query, searches vector store by cosine similarity |
|
|
36
|
+
| `:keyword` | `locate`, `reference` intents; `framework` queries | Searches metadata store by extracted keywords |
|
|
37
|
+
| `:graph` | `trace` intent | Finds seed identifiers, then walks forward and reverse dependency edges |
|
|
38
|
+
| `:hybrid` | `comprehensive` or `exploratory` scope | Runs vector + keyword + graph expansion, deduplicates |
|
|
39
|
+
| `:direct` | `locate`/`reference` + `pinpoint` scope | Looks up identifiers directly in metadata store; falls back to keyword |
|
|
40
|
+
|
|
41
|
+
Keyword results are scored by how many distinct fields matched (identifier, source, metadata), not by the store's result order. Each matched field adds 0.25, capped at 1.0, so a result matching on identifier and source scores higher than one matching source alone.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Configuring Retrieval
|
|
46
|
+
|
|
47
|
+
Retrieval requires an embedding provider and a vector store. Set these in `config/initializers/woods.rb`.
|
|
48
|
+
|
|
49
|
+
### Presets (recommended)
|
|
50
|
+
|
|
51
|
+
Four named presets cover the supported deployment scenarios:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
# Local development: in-memory vectors + SQLite metadata + Ollama.
|
|
55
|
+
# Requires sqlite3, a running Ollama service, and a pulled embedding model.
|
|
56
|
+
Woods.configure_with_preset(:local)
|
|
57
|
+
|
|
58
|
+
# Separate embed/query processes sharing output_dir. No sqlite3 gem.
|
|
59
|
+
# Requires a running Ollama service and a filesystem visible to both processes.
|
|
60
|
+
Woods.configure_with_preset(:shared_filesystem)
|
|
61
|
+
|
|
62
|
+
# PostgreSQL: pgvector + SQLite metadata + OpenAI.
|
|
63
|
+
# Requires pgvector, sqlite3, and an OpenAI API key.
|
|
64
|
+
Woods.configure_with_preset(:postgresql) do |config|
|
|
65
|
+
config.embedding_options = { api_key: ENV.fetch('OPENAI_API_KEY') }
|
|
66
|
+
config.vector_store_options = { connection: ActiveRecord::Base.connection }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Production: Qdrant + SQLite metadata + OpenAI.
|
|
70
|
+
# Requires Qdrant, sqlite3, and an OpenAI API key.
|
|
71
|
+
Woods.configure_with_preset(:production) do |config|
|
|
72
|
+
config.embedding_options = { api_key: ENV.fetch('OPENAI_API_KEY') }
|
|
73
|
+
config.vector_store_options = {
|
|
74
|
+
url: ENV.fetch('QDRANT_URL'),
|
|
75
|
+
collection: ENV.fetch('WOODS_QDRANT_COLLECTION', 'woods'),
|
|
76
|
+
allow_private_hosts: true # only when QDRANT_URL is deliberately private
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Presets accept a block for overrides:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
Woods.configure_with_preset(:local) { |config| config.max_context_tokens = 12_000 }
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Manual configuration
|
|
88
|
+
|
|
89
|
+
**MySQL host app (Qdrant required. MySQL has no native vector extension):**
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
Woods.configure do |config|
|
|
93
|
+
config.vector_store = :qdrant
|
|
94
|
+
config.vector_store_options = {
|
|
95
|
+
url: ENV.fetch('QDRANT_URL'),
|
|
96
|
+
collection: 'myapp',
|
|
97
|
+
allow_private_hosts: true # required for trusted localhost/RFC1918 URLs
|
|
98
|
+
}
|
|
99
|
+
config.metadata_store = :sqlite
|
|
100
|
+
config.embedding_provider = :openai
|
|
101
|
+
config.embedding_options = { api_key: ENV['OPENAI_API_KEY'] }
|
|
102
|
+
config.embedding_model = 'text-embedding-3-small'
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**PostgreSQL host app (pgvector, all-in-one):**
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
Woods.configure do |config|
|
|
110
|
+
config.vector_store = :pgvector
|
|
111
|
+
# pgvector takes a live PostgreSQL connection object, not a URL.
|
|
112
|
+
config.vector_store_options = { connection: ActiveRecord::Base.connection }
|
|
113
|
+
config.metadata_store = :sqlite
|
|
114
|
+
config.embedding_provider = :openai
|
|
115
|
+
config.embedding_options = { api_key: ENV['OPENAI_API_KEY'] }
|
|
116
|
+
config.embedding_model = 'text-embedding-3-small'
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
After configuring, generate embeddings before running retrieval:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
bundle exec rake woods:extract
|
|
124
|
+
bundle exec rake woods:embed
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Running Retrieval
|
|
130
|
+
|
|
131
|
+
### MCP tool: `codebase_retrieve`
|
|
132
|
+
|
|
133
|
+
The primary interface for agents. Available in the Index Server when an embedding provider is configured and `rake woods:embed` has been run.
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
codebase_retrieve(query: "how does billing work?")
|
|
137
|
+
codebase_retrieve(query: "what callbacks run when an order is placed?", budget: 12000)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Parameters:
|
|
141
|
+
|
|
142
|
+
| Parameter | Type | Default | Description |
|
|
143
|
+
|-----------|------|---------|-------------|
|
|
144
|
+
| `query` | string | required | Natural-language question |
|
|
145
|
+
| `budget` | integer | 8000 | Token budget for context assembly |
|
|
146
|
+
|
|
147
|
+
The tool returns a formatted context string ready for use in a prompt, along with source attributions. Use `search` for exact name/pattern lookups; use `codebase_retrieve` for conceptual or behavioral questions.
|
|
148
|
+
|
|
149
|
+
### Ruby API
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
retriever = Woods::Retriever.new(
|
|
153
|
+
vector_store: vector_store,
|
|
154
|
+
metadata_store: metadata_store,
|
|
155
|
+
graph_store: graph_store,
|
|
156
|
+
embedding_provider: embedding_provider
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
result = retriever.retrieve("How does the User model work?")
|
|
160
|
+
|
|
161
|
+
result.context # => "Codebase: 42 units...\n\n---\n\n## User (model)\n..."
|
|
162
|
+
result.strategy # => :hybrid
|
|
163
|
+
result.tokens_used # => 4200
|
|
164
|
+
result.sources # => [{ identifier: "User", type: "model", score: 0.91, ... }]
|
|
165
|
+
result.trace # => RetrievalTrace with elapsed_ms, candidate_count, etc.
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Override the token budget per call:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
result = retriever.retrieve("explain the checkout flow", budget: 16_000)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`Woods.build_retriever` instantiates a retriever from the current configuration:
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
Woods.configure_with_preset(:postgresql) do |config|
|
|
178
|
+
config.embedding_options = { api_key: ENV.fetch('OPENAI_API_KEY') }
|
|
179
|
+
config.vector_store_options = { connection: ActiveRecord::Base.connection }
|
|
180
|
+
end
|
|
181
|
+
retriever = Woods.build_retriever
|
|
182
|
+
result = retriever.retrieve("what validations does Order have?")
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Degradation Tiers
|
|
188
|
+
|
|
189
|
+
Retrieval degrades gracefully when components are unavailable. The Retriever itself does not implement explicit fallback tiers, degradation happens naturally through how each component handles errors:
|
|
190
|
+
|
|
191
|
+
- **Embedding provider unavailable**: `codebase_retrieve` returns a structured configuration error. Check `woods_status` for retrieval readiness.
|
|
192
|
+
- **Vector store unavailable**: vector and hybrid strategies fail at query time. Keyword and graph strategies remain available for direct calls to `SearchExecutor`.
|
|
193
|
+
- **Metadata store error**: the structural context overview (unit counts by type) is silently omitted; `Retriever#build_structural_context` rescues `StandardError` and returns `nil`. The retrieval result is still returned without the overview.
|
|
194
|
+
- **Graph store unavailable**: graph expansion in hybrid strategy produces no graph candidates; vector and keyword candidates are still ranked and returned.
|
|
195
|
+
|
|
196
|
+
In all cases, errors in individual components produce empty candidate sets for that source rather than raising through the `Retriever`. Configure circuit breakers via `Woods::Resilience::CircuitBreaker` on external providers (Qdrant, OpenAI) for production deployments.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Tuning
|
|
201
|
+
|
|
202
|
+
### `similarity_threshold`
|
|
203
|
+
|
|
204
|
+
Controls which vector search results are considered. Range: `0.0`–`1.0`. Default: `0.7`.
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
config.similarity_threshold = 0.6 # Include less similar results (broader)
|
|
208
|
+
config.similarity_threshold = 0.8 # Require higher similarity (narrower)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Lower values return more candidates, which can improve recall for broad queries at the cost of precision. Raise it if results seem loosely related.
|
|
212
|
+
|
|
213
|
+
### `max_context_tokens`
|
|
214
|
+
|
|
215
|
+
Sets the default token budget for context assembly. Default: `8000`. The `budget` parameter on `codebase_retrieve` and `Retriever#retrieve` overrides this per call.
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
config.max_context_tokens = 12_000 # More context per retrieval
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The `ContextAssembler` carves off 10% for the structural overview first, then splits what's left:
|
|
222
|
+
|
|
223
|
+
- **Framework context active** (the query mentions Rails/framework keywords, `rails`, `activerecord`, `middleware`, etc.): primary 55%, supporting 25%, framework 20%.
|
|
224
|
+
- **No framework context**: primary 65%, supporting 35%, the framework section gets nothing, and its share is not proportionally folded into the other two; the fractions are just different, not rescaled.
|
|
225
|
+
|
|
226
|
+
Separately, if the supporting section ends up with no candidates (it only ever holds `:graph_expansion` results), its reserved budget is reclaimed into primary rather than wasted.
|
|
227
|
+
|
|
228
|
+
### `context_format`
|
|
229
|
+
|
|
230
|
+
Controls how assembled units are formatted. Default: `:markdown`. Valid values: `:claude`, `:markdown`, `:plain`, `:json`.
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
config.context_format = :claude # XML-wrapped output for Claude-style context
|
|
234
|
+
config.context_format = :json # Machine-readable output
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Switching embedding models
|
|
238
|
+
|
|
239
|
+
The embedding model must match between `rake woods:embed` and retrieval. Different models produce vectors with different dimensionalities. Woods raises `Woods::MCP::DimensionMismatch` when they disagree, at embed time for durable stores and at MCP boot for dumps. After changing `embedding_model`, drop the vector store and re-run full extraction and embedding:
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
bundle exec rake woods:extract
|
|
243
|
+
bundle exec rake woods:embed
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
**OpenAI model dimensions:**
|
|
247
|
+
|
|
248
|
+
| Model | Dimensions |
|
|
249
|
+
|-------|-----------|
|
|
250
|
+
| `text-embedding-3-small` (default) | 1536 |
|
|
251
|
+
| `text-embedding-3-large` | 3072 |
|
|
252
|
+
|
|
253
|
+
**Ollama default model:** `nomic-embed-text`. Dimensions are detected dynamically on first embed.
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## Troubleshooting
|
|
258
|
+
|
|
259
|
+
| Symptom | Likely cause | Fix |
|
|
260
|
+
|---------|-------------|-----|
|
|
261
|
+
| `codebase_retrieve` returns no results | Embeddings not generated, or embedding provider not configured | Run `rake woods:embed`; verify `embedding_provider` is set and API key is valid |
|
|
262
|
+
| Results are stale or missing recent changes | Index not updated after code changes | Run `rake woods:incremental` (or `rake woods:extract` for route/event changes) |
|
|
263
|
+
| Dimension mismatch warning in logs | `embedding_model` changed after embedding was generated | Re-run `rake woods:extract && rake woods:embed` with the new model |
|
|
264
|
+
| Empty results for a known class name | Keyword strategy not finding the identifier | Try a conceptual query with `codebase_retrieve`; or use `search` for exact name lookup |
|
|
265
|
+
| Very slow retrieval | Large vector index without HNSW index, or Qdrant cold start | For pgvector: create an HNSW index (see `BACKEND_MATRIX.md`). For Qdrant: check collection status |
|
|
266
|
+
| `codebase_retrieve` tool listed but disabled | Embedding provider not configured or API key missing | Set `embedding_provider`, run `woods:embed`, and check `woods_status` |
|
|
267
|
+
| Results clustered around one type | Diversity penalty insufficient for codebase shape | Lower `similarity_threshold` slightly and widen the query scope |
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Token Estimation Benchmark
|
|
2
|
+
|
|
3
|
+
> **Single source of truth:** `Woods::TokenUtils.chars_per_token_for(provider)`
|
|
4
|
+
> in `lib/woods/token_utils.rb`. Production code uses **4.0 chars/token** for
|
|
5
|
+
> the OpenAI path and **1.5 chars/token** for the Ollama / WordPiece path.
|
|
6
|
+
> Both are applied consistently by `Woods::Builder#chars_per_token_for`,
|
|
7
|
+
> `ContextAssembler`, `TextPreparer`, and `ExtractedUnit#estimated_tokens`.
|
|
8
|
+
> The cost-model layer (`lib/woods/cost_model/`) is the one exception, it
|
|
9
|
+
> uses its own pre-aggregated `TOKENS_PER_CHUNK` constant (450) rather than
|
|
10
|
+
> a per-string ratio, since it measures a different thing (per-chunk average
|
|
11
|
+
> vs. per-string chars/token).
|
|
12
|
+
>
|
|
13
|
+
> When the optional [`tokenizers`](https://github.com/ankane/tokenizers-ruby)
|
|
14
|
+
> gem is installed, the Ollama path uses the real BERT WordPiece tokenizer
|
|
15
|
+
> (`Woods::Embedding::TokenCounter`) instead of this heuristic. The 4.0
|
|
16
|
+
> divisor below is what the gem falls back to everywhere else.
|
|
17
|
+
|
|
18
|
+
This is a historical record of the benchmark that picked 4.0 over the
|
|
19
|
+
original 3.5 divisor. It is cited from five places in `lib/` as the evidence
|
|
20
|
+
for that choice, keep the numbers below intact if you edit this doc.
|
|
21
|
+
|
|
22
|
+
## What was measured
|
|
23
|
+
|
|
24
|
+
- **Corpus**: 19 Ruby source files from `lib/woods/` (1.4 KB–33.4 KB): extractors, retrieval pipeline, MCP servers, and utility modules.
|
|
25
|
+
- **Reference tokenizer**: tiktoken_ruby with cl100k_base (GPT-4) and o200k_base (GPT-4o).
|
|
26
|
+
- **Heuristic under test**: `(content.length / N).ceil`, comparing divisors 3.0–4.0.
|
|
27
|
+
|
|
28
|
+
## Results
|
|
29
|
+
|
|
30
|
+
| Divisor | Mean Abs Error (cl100k) | Max Abs Error |
|
|
31
|
+
|--------:|------------------------:|--------------:|
|
|
32
|
+
| 3.0 | 47.2% | 80.5% |
|
|
33
|
+
| 3.2 | 38.0% | 69.2% |
|
|
34
|
+
| 3.5 (previous default) | 26.2% | 54.7% |
|
|
35
|
+
| 3.8 | 16.2% | 42.5% |
|
|
36
|
+
| **4.0 (shipped)** | **10.6%** | **35.4%** |
|
|
37
|
+
|
|
38
|
+
Mean chars/token across the corpus was **4.41** (range 3.94–5.42). The
|
|
39
|
+
heuristic always overestimated, never underestimated, across all 19 files,
|
|
40
|
+
which is what makes it safe for token-limit enforcement even at its worst
|
|
41
|
+
case. Code lines and comment/YARD lines had similar ratios (4.38 vs. 4.27
|
|
42
|
+
chars/token), no separate handling needed for either.
|
|
43
|
+
|
|
44
|
+
## What shipped
|
|
45
|
+
|
|
46
|
+
**The divisor changed from 3.5 to 4.0.** It roughly halves the mean
|
|
47
|
+
overestimate (26.2% → 10.6%) while keeping the conservative
|
|
48
|
+
always-overestimates property, at zero new runtime dependencies. The
|
|
49
|
+
constant lives in one place now (`Woods::TokenUtils::CHARS_PER_TOKEN_BY_PROVIDER`),
|
|
50
|
+
not scattered across call sites, see `lib/woods/token_utils.rb` for the
|
|
51
|
+
current definition and `docs/EMBEDDING_MODELS.md` for the Ollama-side ratio.
|
|
52
|
+
|
|
53
|
+
**tiktoken_ruby was deliberately not added as a runtime dependency.** A 10.6%
|
|
54
|
+
mean error is acceptable for chunking decisions, budget estimates, and
|
|
55
|
+
truncation; a native-extension dependency for marginal accuracy gains wasn't
|
|
56
|
+
worth it. The optional `tokenizers` gem covers the case where exact counts
|
|
57
|
+
matter more (see above).
|
|
58
|
+
|
|
59
|
+
## Reproducing this benchmark
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
gem install tiktoken_ruby # not a Gemfile dependency, benchmark only
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`spec/token_estimation_benchmark_spec.rb` keeps this honest going forward: it
|
|
66
|
+
checks the heuristic is self-consistent (monotonically increasing with
|
|
67
|
+
content length) and, when tiktoken_ruby is installed, that overestimation
|
|
68
|
+
stays bounded with no underestimation greater than 5%.
|