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.
Files changed (274) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2035 -0
  3. data/CONTRIBUTING.md +253 -87
  4. data/README.md +161 -513
  5. data/SECURITY.md +92 -0
  6. data/assets/woods-wordmark-white-with-bg.png +0 -0
  7. data/docs/AGENT_GUIDE.md +204 -0
  8. data/docs/AGENT_SETUP.md +205 -0
  9. data/docs/BACKEND_MATRIX.md +470 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +655 -0
  11. data/docs/CONSOLE_MCP_SETUP.md +829 -0
  12. data/docs/DOCKER_SETUP.md +454 -0
  13. data/docs/EMBEDDING_MODELS.md +136 -0
  14. data/docs/EVALUATION.md +91 -0
  15. data/docs/EXTRACTOR_REFERENCE.md +765 -0
  16. data/docs/FAQ.md +544 -0
  17. data/docs/GETTING_STARTED.md +183 -0
  18. data/docs/INCREMENTAL_EXTRACTION.md +455 -0
  19. data/docs/INTERNALS.md +418 -0
  20. data/docs/MCP_HTTP_TRANSPORT.md +144 -0
  21. data/docs/MCP_SERVERS.md +231 -0
  22. data/docs/MCP_TOOL_COOKBOOK.md +987 -0
  23. data/docs/MCP_WORKTREE_SETUP.md +127 -0
  24. data/docs/NOTION_INTEGRATION.md +283 -0
  25. data/docs/OBSIDIAN_INTEGRATION.md +170 -0
  26. data/docs/PUBLISHED_INDEX.md +213 -0
  27. data/docs/README.md +94 -0
  28. data/docs/RETRIEVAL_GUIDE.md +267 -0
  29. data/docs/TOKEN_BENCHMARK.md +68 -0
  30. data/docs/TROUBLESHOOTING.md +841 -0
  31. data/docs/UNBLOCKED_INTEGRATION.md +279 -0
  32. data/docs/UPGRADING_TO_2.md +321 -0
  33. data/docs/WATCH_DAEMON.md +667 -0
  34. data/docs/WHY_WOODS.md +219 -0
  35. data/exe/woods-console +40 -4
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +80 -11
  39. data/exe/woods-mcp-start +57 -52
  40. data/lib/generators/woods/install_generator.rb +6 -5
  41. data/lib/generators/woods/pgvector_generator.rb +6 -3
  42. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
  43. data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
  44. data/lib/generators/woods/templates/woods.rb.tt +49 -28
  45. data/lib/tasks/woods.rake +622 -168
  46. data/lib/tasks/woods_checks.rake +107 -0
  47. data/lib/tasks/woods_evaluation.rake +164 -80
  48. data/lib/woods/ast/call_site_extractor.rb +6 -15
  49. data/lib/woods/ast/method_extractor.rb +19 -9
  50. data/lib/woods/ast/parser.rb +54 -8
  51. data/lib/woods/atomic_file.rb +171 -2
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +7 -2
  54. data/lib/woods/cache/cache_store.rb +9 -1
  55. data/lib/woods/cache/solid_cache_store.rb +6 -4
  56. data/lib/woods/change_set.rb +88 -0
  57. data/lib/woods/checks/generation_resolution.rb +34 -0
  58. data/lib/woods/checks/moved_messages.rb +186 -0
  59. data/lib/woods/chunking/semantic_chunker.rb +160 -18
  60. data/lib/woods/console/audit_logger.rb +12 -3
  61. data/lib/woods/console/bridge_protocol.rb +3 -16
  62. data/lib/woods/console/connection_manager.rb +51 -136
  63. data/lib/woods/console/dispatch_pipeline.rb +42 -12
  64. data/lib/woods/console/embedded_executor.rb +806 -149
  65. data/lib/woods/console/eval_guard.rb +27 -20
  66. data/lib/woods/console/input_contract.rb +78 -0
  67. data/lib/woods/console/model_validator.rb +29 -1
  68. data/lib/woods/console/rack_middleware.rb +65 -42
  69. data/lib/woods/console/redactor.rb +26 -8
  70. data/lib/woods/console/safe_context.rb +58 -10
  71. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  72. data/lib/woods/console/server.rb +119 -247
  73. data/lib/woods/console/sql_noise_stripper.rb +125 -16
  74. data/lib/woods/console/sql_table_scanner.rb +82 -22
  75. data/lib/woods/console/sql_validator.rb +459 -29
  76. data/lib/woods/console/table_gate.rb +2 -2
  77. data/lib/woods/console/tool_specs.rb +463 -90
  78. data/lib/woods/console/tools/tier1.rb +1 -5
  79. data/lib/woods/console/tools/tier4.rb +18 -9
  80. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  81. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  82. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  83. data/lib/woods/db/migrator.rb +3 -9
  84. data/lib/woods/db/schema_version.rb +47 -2
  85. data/lib/woods/dependency_graph.rb +898 -64
  86. data/lib/woods/embedding/fake.rb +138 -0
  87. data/lib/woods/embedding/indexer.rb +832 -40
  88. data/lib/woods/embedding/openai.rb +77 -19
  89. data/lib/woods/embedding/provider.rb +189 -11
  90. data/lib/woods/embedding/text_preparer.rb +1 -1
  91. data/lib/woods/embedding/token_counter.rb +0 -7
  92. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  93. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  94. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  95. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  96. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  97. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  98. data/lib/woods/evaluation/ablation_task.rb +66 -0
  99. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  100. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  101. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  102. data/lib/woods/evaluation/baseline.rb +60 -0
  103. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  104. data/lib/woods/evaluation/evaluator.rb +41 -8
  105. data/lib/woods/evaluation/query_set.rb +79 -13
  106. data/lib/woods/evaluation/report_generator.rb +20 -1
  107. data/lib/woods/export/unit_facts.rb +0 -11
  108. data/lib/woods/extracted_unit.rb +22 -63
  109. data/lib/woods/extractor.rb +2783 -238
  110. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  111. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  112. data/lib/woods/extractors/caching_extractor.rb +46 -12
  113. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  114. data/lib/woods/extractors/component_discovery.rb +123 -0
  115. data/lib/woods/extractors/concern_extractor.rb +17 -3
  116. data/lib/woods/extractors/controller_extractor.rb +389 -29
  117. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  118. data/lib/woods/extractors/engine_extractor.rb +53 -8
  119. data/lib/woods/extractors/event_extractor.rb +55 -4
  120. data/lib/woods/extractors/factory_extractor.rb +49 -11
  121. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  122. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  123. data/lib/woods/extractors/job_extractor.rb +51 -21
  124. data/lib/woods/extractors/lib_extractor.rb +23 -17
  125. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  126. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  127. data/lib/woods/extractors/manager_extractor.rb +19 -2
  128. data/lib/woods/extractors/migration_extractor.rb +22 -11
  129. data/lib/woods/extractors/model_extractor.rb +292 -57
  130. data/lib/woods/extractors/package_extractor.rb +154 -0
  131. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  132. data/lib/woods/extractors/policy_extractor.rb +6 -5
  133. data/lib/woods/extractors/poro_extractor.rb +13 -14
  134. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  135. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  136. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  137. data/lib/woods/extractors/reference_patterns.rb +38 -0
  138. data/lib/woods/extractors/route_extractor.rb +58 -2
  139. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  140. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  141. data/lib/woods/extractors/service_extractor.rb +11 -1
  142. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  143. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  144. data/lib/woods/extractors/source_nesting.rb +560 -0
  145. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  146. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  147. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  148. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  149. data/lib/woods/feedback/gap_detector.rb +9 -3
  150. data/lib/woods/feedback/store.rb +7 -1
  151. data/lib/woods/filename_utils.rb +29 -1
  152. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  153. data/lib/woods/flow_assembler.rb +147 -26
  154. data/lib/woods/flow_document.rb +1 -0
  155. data/lib/woods/flow_precomputer.rb +175 -22
  156. data/lib/woods/gem_mapper.rb +285 -0
  157. data/lib/woods/generation.rb +185 -0
  158. data/lib/woods/git_command.rb +38 -0
  159. data/lib/woods/git_provenance.rb +16 -2
  160. data/lib/woods/graph_analyzer.rb +564 -87
  161. data/lib/woods/index_artifact.rb +93 -23
  162. data/lib/woods/mcp/bearer_auth.rb +102 -13
  163. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  164. data/lib/woods/mcp/bootstrapper.rb +582 -77
  165. data/lib/woods/mcp/config_resolver.rb +66 -6
  166. data/lib/woods/mcp/errors.rb +60 -0
  167. data/lib/woods/mcp/index_reader.rb +836 -117
  168. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  169. data/lib/woods/mcp/origin_guard.rb +66 -7
  170. data/lib/woods/mcp/protocol_policy.rb +98 -0
  171. data/lib/woods/mcp/provider_probe.rb +45 -6
  172. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  173. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  174. data/lib/woods/mcp/server.rb +898 -152
  175. data/lib/woods/mcp/tasks/extension.rb +196 -0
  176. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  177. data/lib/woods/mcp/tasks/store.rb +518 -0
  178. data/lib/woods/mcp/tool_contract.rb +171 -0
  179. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  180. data/lib/woods/model_name_cache.rb +19 -1
  181. data/lib/woods/notion/client.rb +132 -36
  182. data/lib/woods/notion/exporter.rb +456 -61
  183. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  184. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  185. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  186. data/lib/woods/notion/mappers/shared.rb +45 -3
  187. data/lib/woods/notion/sync_manifest.rb +258 -0
  188. data/lib/woods/obsidian/errors.rb +6 -0
  189. data/lib/woods/obsidian/name_mapper.rb +40 -24
  190. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  191. data/lib/woods/operator/pipeline_guard.rb +118 -21
  192. data/lib/woods/operator/status_reporter.rb +20 -3
  193. data/lib/woods/path_dispatcher.rb +276 -0
  194. data/lib/woods/payload_store.rb +236 -0
  195. data/lib/woods/published_index/edge_shaper.rb +61 -0
  196. data/lib/woods/published_index/generation_catalog.rb +72 -0
  197. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  198. data/lib/woods/published_index.rb +287 -0
  199. data/lib/woods/railtie.rb +69 -30
  200. data/lib/woods/railtie_support.rb +167 -0
  201. data/lib/woods/release.rb +12 -0
  202. data/lib/woods/reload_policy.rb +206 -0
  203. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  204. data/lib/woods/resilience/index_validator.rb +296 -10
  205. data/lib/woods/resilience/retryable_provider.rb +71 -6
  206. data/lib/woods/resolved_config.rb +55 -11
  207. data/lib/woods/retrieval/context_assembler.rb +132 -40
  208. data/lib/woods/retrieval/query_classifier.rb +26 -8
  209. data/lib/woods/retrieval/ranker.rb +193 -28
  210. data/lib/woods/retrieval/search_executor.rb +206 -39
  211. data/lib/woods/retriever.rb +317 -71
  212. data/lib/woods/retry_after.rb +22 -2
  213. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  214. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  215. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  216. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  217. data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
  218. data/lib/woods/ruby_analyzer.rb +21 -5
  219. data/lib/woods/session_tracer/file_store.rb +138 -19
  220. data/lib/woods/session_tracer/middleware.rb +1 -2
  221. data/lib/woods/session_tracer/redis_store.rb +122 -12
  222. data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
  223. data/lib/woods/session_tracer/session_flow_document.rb +56 -14
  224. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  225. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  226. data/lib/woods/session_tracer/store.rb +14 -1
  227. data/lib/woods/storage/metadata_store.rb +230 -26
  228. data/lib/woods/storage/pgvector.rb +180 -22
  229. data/lib/woods/storage/qdrant.rb +367 -41
  230. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  231. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  232. data/lib/woods/storage/snapshotter.rb +23 -5
  233. data/lib/woods/storage/vector_store.rb +49 -8
  234. data/lib/woods/storage_identity.rb +28 -0
  235. data/lib/woods/tasks.rb +53 -2
  236. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  237. data/lib/woods/temporal/snapshot_store.rb +139 -42
  238. data/lib/woods/unblocked/client.rb +119 -17
  239. data/lib/woods/unblocked/document_builder.rb +34 -2
  240. data/lib/woods/unblocked/exporter.rb +63 -27
  241. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  242. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  243. data/lib/woods/update_check.rb +24 -1
  244. data/lib/woods/util/uuid5.rb +124 -0
  245. data/lib/woods/version.rb +1 -1
  246. data/lib/woods/watch/daemon.rb +1345 -0
  247. data/lib/woods/watch/listen_watcher.rb +81 -0
  248. data/lib/woods/watch/polling_watcher.rb +137 -0
  249. data/lib/woods/watch/status.rb +169 -0
  250. data/lib/woods/watch/tree_scan.rb +163 -0
  251. data/lib/woods/watch/watcher.rb +100 -0
  252. data/lib/woods.rb +138 -9
  253. data/plugin/.claude-plugin/plugin.json +18 -0
  254. data/plugin/hooks/hooks.json +29 -0
  255. data/plugin/hooks/woods-post-edit.sh +226 -0
  256. data/plugin/hooks/woods-session-start.sh +77 -0
  257. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  258. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  259. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  260. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  261. data/plugin/skills/woods-setup/SKILL.md +99 -0
  262. metadata +134 -23
  263. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  264. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  265. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  266. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  268. data/lib/woods/console/bridge.rb +0 -210
  269. data/lib/woods/formatting/claude_adapter.rb +0 -98
  270. data/lib/woods/formatting/generic_adapter.rb +0 -56
  271. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  272. data/lib/woods/notion/mapper.rb +0 -40
  273. data/lib/woods/observability/health_check.rb +0 -79
  274. data/lib/woods/observability/instrumentation.rb +0 -34
@@ -0,0 +1,470 @@
1
+ # Backend Adaptability Matrix
2
+
3
+ ## Purpose
4
+
5
+ Decision guidance for picking a vector store, metadata store, graph store, and embedding provider. Covers what's implemented today, what's still a design target, and how the four shipped presets map to `lib/woods/builder.rb`.
6
+
7
+ ---
8
+
9
+ ## Persistence story
10
+
11
+ Every backend combination falls into one of three shapes based on how data survives process boundaries. The right shape depends on whether the embed process and the query process share a Ruby VM, a filesystem, or neither.
12
+
13
+ | Shape | Vector store | Metadata store | Durability | Right preset |
14
+ |---|---|---|---|---|
15
+ | **Local artifact** | `:in_memory` + dump to `output_dir` | `:sqlite` under `output_dir` | Reopens from the published output artifact | `:local` |
16
+ | **Shared filesystem** | `:in_memory` + `Snapshotter` dump to `output_dir` | `:in_memory` + `Snapshotter` dump to `output_dir` | Process-local, hydrated from disk on MCP boot; dumps retained per `dump_retention_count` (default 3) | `:shared_filesystem` |
17
+ | **Durable vector backend** | `:pgvector` or `:qdrant` | `:sqlite` under `output_dir` | Vectors are external; metadata/config remain a deployed output artifact | `:postgresql`, `:production` |
18
+
19
+ The shape determines the capability matrix:
20
+
21
+ | Capability | Local artifact | Shared filesystem | Durable vector backend |
22
+ |---|---|---|---|
23
+ | Survives process restart | Yes (via output artifact) | Yes (via dump) | Yes (backend + output artifact) |
24
+ | Multi-writer embedding | No | No (single writer assumed) | Do not assume it for the complete index; coordinate one publisher even if the vector backend supports concurrent writes |
25
+ | Requires sqlite3 gem in host | Yes | No | With `:postgresql`/`:production` |
26
+ | Requires embedding/vector service | Ollama | Ollama | OpenAI plus pgvector or Qdrant |
27
+ | Cross-machine query | After deploying/copying `output_dir` | Yes, when the filesystem is shared | External vectors are shared; metadata/config still require a shared or deployed `output_dir` |
28
+ | `woods.json` schema-versioned config snapshot | Yes | Yes | Yes |
29
+
30
+ `Builder#build_vector_store` accepts exactly `:in_memory`, `:pgvector`, `:qdrant`, anything else raises `ArgumentError: Unknown vector_store`. `build_metadata_store` accepts `:in_memory`, `:sqlite`. `build_graph_store` accepts `:in_memory` only. Presets are `:local`, `:shared_filesystem`, `:postgresql`, and `:production`.
31
+
32
+ ---
33
+
34
+ ## Vector stores
35
+
36
+ ### Database compatibility
37
+
38
+ The vector store you can use depends on the primary database your Rails app uses. MySQL stacks **must** pair with an external vector backend; PostgreSQL stacks have the option of running pgvector inside the same database.
39
+
40
+ | Primary database | Supported vector stores | Required? |
41
+ |---|---|---|
42
+ | **MySQL / Percona / MariaDB / Aurora MySQL** | `:qdrant` (external); `:in_memory` (local dev only) | Yes. MySQL has no native vector extension |
43
+ | **PostgreSQL / Aurora PostgreSQL** | `:pgvector` (in-database), `:qdrant`; `:in_memory` (local dev only) | No, `:pgvector` runs inside the same database |
44
+
45
+ **Why MySQL needs an external backend.** MySQL ships no equivalent of the `pgvector` extension. Approximate-nearest-neighbour search over arbitrary float vectors is not part of InnoDB / MyISAM and cannot be added via plugin. Woods does not emulate vector search in MySQL, the gem only ships adapters that delegate to a real vector engine. The shipped pairing for MySQL apps is `:qdrant` for vectors with Woods' own `:sqlite` metadata store; Woods never stores metadata in your application database.
46
+
47
+ ### pgvector (PostgreSQL extension)
48
+
49
+ **What it is:** PostgreSQL extension that adds vector similarity search directly to Postgres.
50
+
51
+ **Best for:** Teams already on PostgreSQL who want to minimize infrastructure. Rails 8 apps with the Solid suite. Codebases under ~5000 units.
52
+
53
+ **Strengths:**
54
+ - Zero additional infrastructure if you're on PostgreSQL
55
+ - Transactional consistency with metadata (same database)
56
+ - Familiar SQL interface, works with ActiveRecord
57
+ - Supports HNSW indexing
58
+ - Backed by strong open-source community
59
+
60
+ **Weaknesses:**
61
+ - Search performance degrades at high scale (>100K vectors) without careful tuning
62
+ - HNSW index builds are memory-intensive
63
+ - Competes for resources with your application database
64
+ - No built-in sharding for vectors
65
+
66
+ **Configuration:**
67
+ ```ruby
68
+ config.vector_store = :pgvector
69
+ # pgvector needs a live PostgreSQL connection object (not a URL string).
70
+ # When your app runs on PostgreSQL, reuse its connection:
71
+ config.vector_store_options = { connection: ActiveRecord::Base.connection }
72
+
73
+ # Dedicated vector database: e.g. a MySQL app pointing at a separate
74
+ # PostgreSQL store: via an abstract class that owns its own connection:
75
+ # class VectorDatabase < ActiveRecord::Base
76
+ # self.abstract_class = true
77
+ # establish_connection(ENV.fetch("VECTOR_DATABASE_URL"))
78
+ # end
79
+ # config.vector_store_options = { connection: VectorDatabase.connection }
80
+ ```
81
+
82
+ `vector_store_options` also accepts `:table` (default `woods_vectors`) and `:schema` (both optional); `:dimensions` is inferred from the embedding provider. `Builder#build_pgvector_store` requires `vector_store_options[:connection]` and raises if it is missing.
83
+
84
+ **Schema** (what `Woods::Storage::VectorStore::Pgvector#ensure_schema!` actually creates, safe to call repeatedly, uses `IF NOT EXISTS`):
85
+ ```sql
86
+ CREATE EXTENSION IF NOT EXISTS vector;
87
+
88
+ CREATE TABLE IF NOT EXISTS woods_vectors (
89
+ id TEXT PRIMARY KEY,
90
+ embedding vector(1536),
91
+ metadata JSONB DEFAULT '{}',
92
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
93
+ );
94
+
95
+ CREATE INDEX IF NOT EXISTS idx_woods_vectors_embedding_hnsw
96
+ ON woods_vectors USING hnsw (embedding vector_cosine_ops);
97
+ ```
98
+
99
+ **Performance notes:**
100
+ - HNSW: ~5ms search at 10K vectors, ~20ms at 100K. Memory: ~1.5x vector size.
101
+ - For codebase indexing (~1000-5000 units, potentially 5000-20000 chunks), HNSW is appropriate.
102
+ - Recommend a separate database from your application if running in production.
103
+
104
+ **When to use:** PostgreSQL is your primary database, you value simplicity, and scale is under ~50K vectors.
105
+
106
+ **When to avoid:** MySQL is your primary database (can't use pgvector), you need sub-millisecond search, or you're indexing multiple large codebases.
107
+
108
+ ---
109
+
110
+ ### Qdrant
111
+
112
+ **What it is:** Purpose-built vector database with native filtering, written in Rust.
113
+
114
+ **Best for:** Teams with Docker-based infrastructure who want dedicated vector search. Self-hosted environments. Multi-codebase indexing.
115
+
116
+ **Strengths:**
117
+ - Purpose-built for vector search (consistently fast)
118
+ - Native payload filtering (no joins needed)
119
+ - Built-in quantization for memory efficiency
120
+ - Excellent Docker support, trivial to add to docker-compose
121
+ - gRPC and REST APIs
122
+
123
+ **Weaknesses:**
124
+ - Additional infrastructure to manage
125
+ - Separate from your application database (no transactional consistency)
126
+ - Overkill for small codebases
127
+
128
+ **Configuration:**
129
+ ```ruby
130
+ config.vector_store = :qdrant
131
+ config.vector_store_options = {
132
+ url: ENV.fetch("QDRANT_URL", "http://localhost:6333"),
133
+ collection: "woods",
134
+ api_key: ENV["QDRANT_API_KEY"], # optional; omit for unauthenticated local instances
135
+ dimensions: 1_536, # optional; pre-validates vector length client-side
136
+ distance: "Cosine", # Cosine, Dot, Euclid, or Manhattan; verified on reopen
137
+ allow_private_hosts: true # required for localhost/RFC1918 URLs, the SSRF guard blocks them by default
138
+ }
139
+ ```
140
+
141
+ The adapter constructor takes these as keyword arguments (`Woods::Storage::VectorStore::Qdrant`); `Builder#build_vector_store` splats `vector_store_options` straight into it. Works identically whether your application database is MySQL or PostgreSQL. Qdrant is a separate service either way.
142
+
143
+ When an installed `woods-mcp` process reopens `woods.json` without the host initializer, non-secret options such as collection, distance, table, schema, and dimensions come from the snapshot. Credentials and process-specific connections remain serve-time settings:
144
+
145
+ - `OPENAI_API_KEY` supplies the embedding credential for OpenAI snapshots.
146
+ - Qdrant endpoint URLs and API keys are never stored in `woods.json`. `WOODS_QDRANT_URL` is required when serving a Qdrant index; `WOODS_QDRANT_API_KEY` is optional, and `WOODS_QDRANT_COLLECTION` supplies a collection only when the snapshot does not record one.
147
+ - `WOODS_PG_URL` is required to construct the Active Record connection for a pgvector snapshot outside its host application.
148
+
149
+ SQLite metadata is always reopened as `metadata.sqlite3` beneath the supplied index directory, never relative to the MCP process working directory.
150
+
151
+ **Point IDs.** Qdrant accepts only an unsigned integer or a UUID as a point id, so the adapter cannot store a Woods identifier directly. It derives a deterministic UUIDv5 from the identifier over a pinned namespace (`Qdrant::POINT_ID_NAMESPACE`) and carries the identifier in the payload under `woods_identifier`; `#search` reverse-maps hits back to identifiers and `#delete` translates through the same function. The namespace must never change: a v5 id is what makes re-embedding an unchanged unit *replace* its point instead of adding a second one. See #147.
152
+
153
+ **Sharing a collection.** `#each_id` enumerates only points carrying a `woods_identifier` payload — points another writer put in the same collection are skipped, not yielded. It backs the embed pipeline's staleness sweep, which deletes anything it enumerates that extraction no longer holds, so yielding a foreign point would destroy another system's vectors on every run. The Indexer applies the same rule a second time, ignoring vanished ids shaped like a canonical UUID or an integer — shapes Woods never mints as an identifier.
154
+
155
+ **Docker Compose:**
156
+ ```yaml
157
+ services:
158
+ qdrant:
159
+ image: qdrant/qdrant:v1.12.1
160
+ ports:
161
+ - "6333:6333"
162
+ - "6334:6334"
163
+ volumes:
164
+ - qdrant-data:/qdrant/storage
165
+ environment:
166
+ QDRANT__SERVICE__GRPC_PORT: 6334
167
+ deploy:
168
+ resources:
169
+ limits:
170
+ memory: 512M # Sufficient for ~50K vectors
171
+
172
+ volumes:
173
+ qdrant-data:
174
+ ```
175
+
176
+ **Performance notes:**
177
+ - ~2ms search at 10K vectors, ~5ms at 100K
178
+ - Memory: ~100MB for 10K 1536-dim vectors with HNSW
179
+ - Quantization can reduce memory by 4x with minimal quality loss
180
+
181
+ **When to use:** Docker infrastructure, self-hosted, need for filtered search, multi-codebase, or want separation of concerns between app DB and vector search.
182
+
183
+ **When to avoid:** Minimal infrastructure footprint is a priority, or team doesn't want another service to manage.
184
+
185
+ ---
186
+
187
+ ### Not implemented
188
+
189
+ These are aspirational; setting `config.vector_store` to any of them raises `ArgumentError: Unknown vector_store`, and there is no `vector_store_api_key` / `vector_store_environment` / `vector_store_index` accessor on `Configuration`, code written against the examples below will not run against the shipped gem. The interface a real adapter must implement is `Woods::Storage::VectorStore::Interface` (`store`, `search`, `delete`, `each_id`); see `Woods::Storage::VectorStore::Pgvector` or `Qdrant` for a working example to model a new adapter on.
190
+
191
+ | Backend | Status | Notes |
192
+ |---|---|---|
193
+ | Pinecone | Planned (#83) | Managed cloud vector DB. Would suit teams that want zero ops and accept vendor lock-in and data leaving the infrastructure. |
194
+ | SQLite-vss / FAISS | Planned | File-based local vector search. `:in_memory` (the `:local` preset) already covers the zero-dependency local case today. |
195
+ | Chroma | Not planned | Ruby client is third-party and less mature than the Qdrant/pgvector tooling already shipped. |
196
+ | Milvus | Not planned | Massive-scale, multi-tenant vector DB. Only worth building if a host needs billions of vectors or GPU-accelerated search across many codebases, well beyond single-codebase indexing. |
197
+
198
+ ---
199
+
200
+ ## Embedding providers
201
+
202
+ ### OpenAI text-embedding-3-small
203
+
204
+ **Dimensions:** 1536
205
+ **Max tokens:** 8191
206
+ **Cost:** ~$0.02 per 1M tokens
207
+ **Latency:** ~100ms single, ~500ms batch of 100
208
+
209
+ **Strengths:** Good quality/cost ratio, fast, well-documented, reliable API.
210
+ **Weaknesses:** Data sent to OpenAI, API dependency, not code-optimized.
211
+
212
+ **Best for:** General use, getting started, teams already using OpenAI.
213
+
214
+ ### OpenAI text-embedding-3-large
215
+
216
+ **Dimensions:** 3072
217
+ **Max tokens:** 8191
218
+ **Cost:** ~$0.13 per 1M tokens
219
+ **Latency:** ~150ms single, ~800ms batch of 100
220
+
221
+ **Strengths:** Higher quality than small, supports dimension reduction (can use 1536 dims for compatibility).
222
+ **Weaknesses:** 6.5x cost of small, marginal quality improvement for code.
223
+
224
+ **Best for:** When retrieval quality is paramount and cost is not a concern.
225
+
226
+ ### Ollama (self-hosted)
227
+
228
+ | Model | Native context | Dimensions | Weights | Notes |
229
+ |---|---|---|---|---|
230
+ | `nomic-embed-text` (default) | 2048 | 768 | 274 MB | General-purpose; pull from Ollama before first use |
231
+ | `bge-m3` | **8192** | 1024 | 1.2 GB | Fewer chunks per unit, stronger code-search benchmarks |
232
+ | `snowflake-arctic-embed2` | 8192 | 1024 | 1.2 GB | Multilingual variant of bge-m3 |
233
+ | `mxbai-embed-large` | 512 | 1024 | 670 MB | Best for short text |
234
+ | `all-minilm` | 512 | 384 | 46 MB | Tight-memory environments |
235
+
236
+ **Cost:** Hardware only
237
+ **Latency:** ~200ms single (GPU), ~2s single (CPU)
238
+
239
+ **Strengths:** Fully self-hosted, no data leaves infrastructure, no API costs, works offline.
240
+ **Weaknesses:** Requires GPU for reasonable performance (CPU is 10x slower). `nomic-embed-text`'s 2048-token ceiling requires chunking most real-world Rails units, switch to `bge-m3` for fewer chunks if disk space allows.
241
+
242
+ **Best for:** Security-sensitive environments, air-gapped networks, cost-sensitive at scale.
243
+
244
+ > Ollama's `/api/embed` enforces the model's native context length regardless of the `options.num_ctx` override ([ollama/ollama#14186](https://github.com/ollama/ollama/issues/14186)). Woods advertises the native ceiling per model so the chunker sizes inputs correctly, see [EMBEDDING_MODELS.md](EMBEDDING_MODELS.md).
245
+
246
+ ### Implemented provider boundary
247
+
248
+ `Builder#build_embedding_provider` accepts `:openai`, `:ollama`, and `:fake`.
249
+ The fake provider is deterministic and offline for specs, CI, and sandbox
250
+ contract tests; it does not represent semantic quality. Other values raise
251
+ `ArgumentError`. Voyage Code 3/2 and Anthropic embeddings are not wired up:
252
+
253
+ - **Voyage Code 3 / Code 2**: code-specialized embeddings (1024/1536 dims, up to 32K token context). Would be the best-quality option for code retrieval if implemented; there is no `Woods::Embedding::Provider::Voyage` today.
254
+ - **Anthropic**: Anthropic does not currently offer a standalone embedding API. Monitor for availability.
255
+
256
+ ### Embedding selection guidance (implemented providers only)
257
+
258
+ | Priority | Recommendation |
259
+ |----------|---------------|
260
+ | **Best general-purpose** | OpenAI text-embedding-3-small |
261
+ | **Lowest cost / no external dependencies** | Ollama + `nomic-embed-text` |
262
+ | **Self-hosted + large units** | Ollama + `bge-m3` (8192-token context vs. 2048) |
263
+ | **Maximum quality** | OpenAI text-embedding-3-large |
264
+ | **Offline deterministic tests** | `:fake` (contract testing only, not semantic ranking) |
265
+
266
+ **Critical consideration:** Embedding dimensions must match across your entire index. Changing embedding providers or models requires a full re-index, `rake woods:embed` raises `Woods::MCP::DimensionMismatch` before embedding anything when the configured provider's dimension disagrees with the store's.
267
+
268
+ ---
269
+
270
+ ## Metadata stores
271
+
272
+ `build_metadata_store` accepts `:in_memory` and `:sqlite`. Nothing else is implemented.
273
+
274
+ ### SQLite
275
+
276
+ **Best for:** Local development, zero-dependency setups, testing, and every shipped preset except pure in-memory.
277
+
278
+ **Key features:**
279
+ - JSON1 extension for metadata queries
280
+ - FTS5 for full-text search
281
+ - Zero setup, single-file database (`metadata.sqlite3` under `output_dir`)
282
+
283
+ **Limitations:**
284
+ - Single writer at a time
285
+ - No network access
286
+
287
+ ### In-memory
288
+
289
+ **Best for:** Testing, evaluation, small codebases.
290
+
291
+ Loads from extracted JSON files on startup. All queries run against in-memory hash maps. Fast but ephemeral, nothing survives a process restart without the `Snapshotter` dump (see the Persistence Story table above).
292
+
293
+ ### Not implemented
294
+
295
+ A PostgreSQL or MySQL metadata store (JSONB/JSON columns, generated columns, full-text search, recursive-CTE graph dual-use) is a plausible future adapter, `config.metadata_store = :postgresql` or `:mysql` today raises `ArgumentError: Unknown metadata_store`, and `metadata_store_connection` is not a `Configuration` accessor. For MySQL- or PostgreSQL-backed deployments today, pair `:sqlite` metadata with your vector store of choice (`:pgvector` or `:qdrant`).
296
+
297
+ ---
298
+
299
+ ## Graph stores
300
+
301
+ `build_graph_store` accepts `:in_memory` only.
302
+
303
+ ### In-memory (the only shipped graph store)
304
+
305
+ Loads `dependency_graph.json` into a Ruby hash structure. Supports BFS traversal with visited set, PageRank scoring, and structural analysis via `GraphAnalyzer` (orphan detection, dead-end detection, hub identification, cycle detection, bridge detection). Suitable for up to ~5000 nodes.
306
+
307
+ **Memory:** ~10MB for 2000 nodes with average 5 edges each.
308
+ **Traversal:** < 1ms for depth-2 BFS.
309
+ **Analysis:** `GraphAnalyzer` provides `orphans`, `dead_ends`, `hubs(limit:)`, `cycles`, `bridges(limit:, sample_size:)`, `domain_clusters`, `cross_database_edges`, `volatile_dependencies`, `undeclared_package_edges`, and a combined `analyze` method.
310
+
311
+ ### Not implemented
312
+
313
+ A recursive-CTE graph store (MySQL 8.0+ or PostgreSQL, storing edges in a table and traversing with `WITH RECURSIVE`) or Neo4j would only matter past ~50,000 nodes, or for cross-repository tracing and algorithms beyond PageRank/hub/bridge/cycle detection (weighted shortest path, community detection). Neither exists in the shipped gem; `config.graph_store` set to anything but `:in_memory` raises.
314
+
315
+ ---
316
+
317
+ ## Background job integration
318
+
319
+ Indexing can be triggered synchronously (rake task, inline) or from a background job. The pipeline itself is job-system-agnostic, it's synchronous Ruby, and the wrapper below is just scheduling and concurrency control. Use `Woods.extract!` for a full run; incremental runs need a changed-file list, so a job usually just shells out to `rake woods:incremental` (which computes that list from git) rather than calling `Woods.extract_changed!` directly.
320
+
321
+ ### Sidekiq
322
+
323
+ ```ruby
324
+ class WoodsJob
325
+ include Sidekiq::Job
326
+ sidekiq_options queue: :low, retry: 2
327
+
328
+ def perform(mode = "full")
329
+ case mode
330
+ when "full" then Woods.extract!
331
+ when "incremental" then Rake::Task["woods:incremental"].invoke
332
+ end
333
+ end
334
+ end
335
+ ```
336
+
337
+ ### Solid queue (Rails 8)
338
+
339
+ ```ruby
340
+ class WoodsJob < ApplicationJob
341
+ queue_as :low_priority
342
+ limits_concurrency to: 1, key: "woods"
343
+
344
+ def perform(mode = "full")
345
+ case mode
346
+ when "full" then Woods.extract!
347
+ when "incremental" then Rake::Task["woods:incremental"].invoke
348
+ end
349
+ end
350
+ end
351
+ ```
352
+
353
+ ### GoodJob
354
+
355
+ ```ruby
356
+ class WoodsJob < ApplicationJob
357
+ queue_as :utility
358
+ retry_on StandardError, wait: :polynomially_longer, attempts: 3
359
+
360
+ def perform(mode = "full")
361
+ # Same interface as above
362
+ end
363
+ end
364
+ ```
365
+
366
+ ### Inline (Development/CI)
367
+
368
+ ```ruby
369
+ # No job system needed
370
+ Woods.extract!
371
+ ```
372
+
373
+ ---
374
+
375
+ ## Recommended stack combinations
376
+
377
+ ### Starter (local dependencies)
378
+
379
+ ```ruby
380
+ Woods.configure_with_preset(:local)
381
+ # Vector: InMemory VectorStore
382
+ # Metadata: SQLite
383
+ # Graph: In-memory
384
+ # Embedding: Ollama (nomic-embed-text)
385
+ # Jobs: Inline
386
+ ```
387
+
388
+ **Setup:** add the `sqlite3` gem, then install/start Ollama and run
389
+ `ollama pull nomic-embed-text`.
390
+ **Tradeoff:** Lower retrieval quality, CPU-bound embedding, single-user.
391
+
392
+ ### Rails 8 standard
393
+
394
+ ```ruby
395
+ Woods.configure_with_preset(:postgresql) do |config|
396
+ config.embedding_options = { api_key: ENV.fetch('OPENAI_API_KEY') }
397
+ config.vector_store_options = { connection: ActiveRecord::Base.connection }
398
+ end
399
+ # Vector: pgvector
400
+ # Metadata: SQLite
401
+ # Graph: In-memory
402
+ # Embedding: OpenAI
403
+ # Jobs: Solid Queue
404
+ ```
405
+
406
+ **Setup:** `bundle add pgvector` + enable extension
407
+ **Tradeoff:** All-in-one database, good quality, API dependency for embeddings.
408
+
409
+ ### MySQL + Qdrant (classic Rails)
410
+
411
+ ```ruby
412
+ # No dedicated :mysql preset exists. Use :production and reuse your MySQL
413
+ # connection for the app itself: Woods' own metadata store stays SQLite.
414
+ Woods.configure_with_preset(:production) do |config|
415
+ config.embedding_options = { api_key: ENV.fetch('OPENAI_API_KEY') }
416
+ config.vector_store_options = {
417
+ url: ENV.fetch('QDRANT_URL'),
418
+ collection: ENV.fetch('WOODS_QDRANT_COLLECTION', 'woods'),
419
+ allow_private_hosts: true # only for a deliberately trusted private endpoint
420
+ }
421
+ end
422
+ # Vector: Qdrant
423
+ # Metadata: SQLite
424
+ # Graph: In-memory
425
+ # Embedding: OpenAI
426
+ # Jobs: Sidekiq
427
+ ```
428
+
429
+ **Setup:** Add Qdrant to docker-compose.
430
+ **Tradeoff:** Leverages existing MySQL infrastructure for the app; Qdrant handles vector search, which MySQL can't do natively. Most natural fit for established Rails apps on MySQL/Percona with Docker and Sidekiq.
431
+
432
+ ### Fully self-hosted
433
+
434
+ ```ruby
435
+ # No dedicated :self_hosted preset exists. Use :production, then override
436
+ # embedding_provider to :ollama.
437
+ Woods.configure_with_preset(:production) do |config|
438
+ config.embedding_provider = :ollama
439
+ config.embedding_options = {
440
+ model: 'nomic-embed-text',
441
+ host: ENV.fetch('OLLAMA_URL', 'http://localhost:11434')
442
+ }
443
+ config.vector_store_options = {
444
+ url: ENV.fetch('QDRANT_URL'),
445
+ collection: ENV.fetch('WOODS_QDRANT_COLLECTION', 'woods'),
446
+ allow_private_hosts: true # Qdrant is deliberately self-hosted/private here
447
+ }
448
+ end
449
+ # Vector: Qdrant
450
+ # Metadata: SQLite
451
+ # Graph: In-memory
452
+ # Embedding: Ollama (nomic-embed-text or bge-m3)
453
+ ```
454
+
455
+ **Setup:** Qdrant + Ollama in docker-compose.
456
+ **Tradeoff:** No external API calls, all data stays on-premise. Works with either database. Embedding quality depends on model choice.
457
+
458
+ ---
459
+
460
+ ## Cost and scale guidance
461
+
462
+ Embedding and vector-storage cost are not the bottleneck for a single-codebase index, the numbers below are the reasoning, not a budgeting exercise.
463
+
464
+ - **Embedding cost is driven by token count, not unit count.** Each unit produces roughly 1–4 chunks (hierarchical chunking: one summary chunk plus semantic sub-chunks), each with a small context prefix. At OpenAI's `text-embedding-3-small` price (~$0.02/1M tokens), a full re-index of a codebase in the thousands-of-units range costs cents, not dollars. Ollama costs nothing per token but requires GPU time.
465
+ - **Incremental re-embedding is cheaper still.** Chunk checksumming means only chunks whose source actually changed get re-embedded, a typical merge touches a handful of units.
466
+ - **Query-time embedding cost scales with query volume, not index size.** One embedding call per `codebase_retrieve` query; even four-digit daily query volumes stay cheap with the small model.
467
+ - **Vector storage is `dimensions × 4 bytes` per vector**, plus adapter overhead. At 1536 dimensions and a few thousand chunks, this is single-digit megabytes, negligible next to the database or Qdrant container it lives in.
468
+ - **Infrastructure cost is the real variable.** `:local` and `:postgresql` add no new service. `:production` (Qdrant) adds one container (~300MB RAM) or a managed-cloud free tier.
469
+
470
+ The actual cost driver is developer time: initial setup (an hour or so per preset), and occasional tuning of chunking/ranking if retrieval quality needs adjustment. Choose the preset that fits infrastructure you already run, the numbers above rarely change that decision.