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
@@ -24,9 +24,10 @@ module Woods
24
24
  # doc = assembler.assemble("abc123", budget: 8000, depth: 1)
25
25
  # puts doc.to_context
26
26
  #
27
- # rubocop:disable Metrics/ClassLength
27
+ # rubocop:disable-next Metrics/ClassLength
28
28
  class SessionFlowAssembler
29
29
  ASYNC_TYPES = %w[job mailer].to_set.freeze
30
+ RENDER_BUDGET_ATTEMPTS = 3
30
31
 
31
32
  # @param store [Store] Session trace store
32
33
  # @param reader [MCP::IndexReader] Index reader for unit lookups
@@ -41,7 +42,7 @@ module Woods
41
42
  # @param budget [Integer] Maximum token budget (default: 8000)
42
43
  # @param depth [Integer] Expansion depth (0=metadata only, 1=direct deps, 2+=full flow)
43
44
  # @return [SessionFlowDocument] The assembled document
44
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
45
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
45
46
  def assemble(session_id, budget: 8000, depth: 1)
46
47
  requests = @store.read(session_id)
47
48
  return empty_document(session_id) if requests.empty?
@@ -77,19 +78,12 @@ module Woods
77
78
  step[:unit_refs].concat(deps)
78
79
  end
79
80
 
80
- # Apply token budget
81
- token_count = apply_budget(context_pool, budget)
82
-
83
- SessionFlowDocument.new(
84
- session_id: session_id,
85
- steps: steps,
86
- context_pool: context_pool,
87
- side_effects: side_effects,
88
- dependency_map: dependency_map,
89
- token_count: token_count
90
- )
81
+ # Apply token budget against the rendered output, not just the pool
82
+ apply_budget(context_pool, budget)
83
+ parts = { session_id: session_id, steps: steps, context_pool: context_pool,
84
+ side_effects: side_effects, dependency_map: dependency_map }
85
+ budgeted_document(parts, budget)
91
86
  end
92
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
93
87
 
94
88
  private
95
89
 
@@ -115,7 +109,7 @@ module Woods
115
109
  # Resolve dependencies for a unit, separating sync deps from async side effects.
116
110
  #
117
111
  # @return [Array<String>] Non-async dependency identifiers added
118
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
112
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
119
113
  def resolve_dependencies(unit_id, seen_units, context_pool,
120
114
  side_effects, step, dependency_map, depth)
121
115
  graph = @reader.dependency_graph
@@ -154,7 +148,6 @@ module Woods
154
148
 
155
149
  added
156
150
  end
157
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
158
151
 
159
152
  # Expand transitive dependencies (depth 2+).
160
153
  #
@@ -241,6 +234,54 @@ module Woods
241
234
  end
242
235
  end
243
236
 
237
+ # The budget target is the RENDERED document: the steps timeline, side
238
+ # effects, dependency map, and markup all cost tokens the pool estimate
239
+ # never counted, so a document could ship over budget while reporting a
240
+ # compliant token_count. Measure to_context, tighten the source pool by
241
+ # the observed overshoot, and re-measure; when nothing truncatable
242
+ # remains, ship the floor flagged budget_exceeded instead of silently
243
+ # over budget.
244
+ def budgeted_document(parts, budget)
245
+ rendered = rendered_tokens(parts)
246
+ (RENDER_BUDGET_ATTEMPTS - 1).times do
247
+ break if rendered <= budget
248
+ break unless shrink_source_pool?(parts[:context_pool], budget, rendered)
249
+
250
+ rendered = rendered_tokens(parts)
251
+ end
252
+
253
+ SessionFlowDocument.new(**parts, token_count: rendered, budget_exceeded: rendered > budget)
254
+ end
255
+
256
+ # to_context embeds the token count itself, so measure to a fixpoint:
257
+ # the reported number must be the measure of the document printing it.
258
+ def rendered_tokens(parts)
259
+ count = 0
260
+ 3.times do
261
+ probe = SessionFlowDocument.new(**parts, token_count: count)
262
+ measured = TokenUtils.estimate_tokens(probe.to_context)
263
+ return measured if measured == count
264
+
265
+ count = measured
266
+ end
267
+ count
268
+ end
269
+
270
+ # Tighten the source-pool budget by the measured structural overshoot.
271
+ # Returns false when no truncatable source remains — the floor.
272
+ def shrink_source_pool?(context_pool, budget, rendered)
273
+ return false unless context_pool.values.any? { |unit| truncatable_source?(unit) }
274
+
275
+ overshoot = rendered - budget
276
+ apply_budget(context_pool, [estimate_tokens(context_pool) - overshoot, 0].max)
277
+ true
278
+ end
279
+
280
+ def truncatable_source?(unit)
281
+ source = unit[:source_code]
282
+ source && !source.start_with?('# source truncated')
283
+ end
284
+
244
285
  # Build an empty document for sessions with no requests.
245
286
  #
246
287
  # @param session_id [String]
@@ -249,6 +290,5 @@ module Woods
249
290
  SessionFlowDocument.new(session_id: session_id)
250
291
  end
251
292
  end
252
- # rubocop:enable Metrics/ClassLength
253
293
  end
254
294
  end
@@ -23,7 +23,7 @@ module Woods
23
23
  # doc.to_markdown # => human-readable document
24
24
  # doc.to_context # => LLM XML format
25
25
  #
26
- # rubocop:disable Metrics/ClassLength
26
+ # rubocop:disable-next Metrics/ClassLength
27
27
  class SessionFlowDocument
28
28
  attr_reader :session_id, :steps, :context_pool, :side_effects,
29
29
  :dependency_map, :token_count, :generated_at
@@ -33,20 +33,28 @@ module Woods
33
33
  # @param context_pool [Hash<String, Hash>] Deduplicated unit data keyed by identifier
34
34
  # @param side_effects [Array<Hash>] Async side effects (jobs, mailers)
35
35
  # @param dependency_map [Hash<String, Array<String>>] Unit -> dependency identifiers
36
- # @param token_count [Integer] Estimated total tokens
36
+ # @param token_count [Integer] Estimated tokens of the rendered context output
37
+ # @param budget_exceeded [Boolean] True when even the fully-truncated
38
+ # rendering exceeds the requested budget, so callers see an honest
39
+ # flag instead of a silently over-budget document
37
40
  # @param generated_at [String, nil] ISO8601 timestamp (defaults to now)
38
- # rubocop:disable Metrics/ParameterLists
41
+ # rubocop:disable-next Metrics/ParameterLists
39
42
  def initialize(session_id:, steps: [], context_pool: {}, side_effects: [],
40
- dependency_map: {}, token_count: 0, generated_at: nil)
43
+ dependency_map: {}, token_count: 0, budget_exceeded: false, generated_at: nil)
41
44
  @session_id = session_id
42
45
  @steps = steps
43
46
  @context_pool = context_pool
44
47
  @side_effects = side_effects
45
48
  @dependency_map = dependency_map
46
49
  @token_count = token_count
50
+ @budget_exceeded = budget_exceeded
47
51
  @generated_at = generated_at || Time.now.utc.iso8601
48
52
  end
49
- # rubocop:enable Metrics/ParameterLists
53
+
54
+ # @return [Boolean]
55
+ def budget_exceeded?
56
+ @budget_exceeded ? true : false
57
+ end
50
58
 
51
59
  # Serialize to a JSON-compatible Hash.
52
60
  #
@@ -56,6 +64,7 @@ module Woods
56
64
  session_id: @session_id,
57
65
  generated_at: @generated_at,
58
66
  token_count: @token_count,
67
+ budget_exceeded: budget_exceeded?,
59
68
  steps: @steps,
60
69
  context_pool: @context_pool,
61
70
  side_effects: @side_effects,
@@ -79,6 +88,7 @@ module Woods
79
88
  side_effects: data[:side_effects] || [],
80
89
  dependency_map: data[:dependency_map] || {},
81
90
  token_count: data[:token_count] || 0,
91
+ budget_exceeded: data.fetch(:budget_exceeded, false),
82
92
  generated_at: data[:generated_at]
83
93
  )
84
94
  end
@@ -86,7 +96,7 @@ module Woods
86
96
  # Render as human-readable Markdown.
87
97
  #
88
98
  # @return [String]
89
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
99
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
90
100
  def to_markdown
91
101
  lines = []
92
102
  lines << "## Session: #{@session_id}"
@@ -146,17 +156,16 @@ module Woods
146
156
 
147
157
  lines.join("\n")
148
158
  end
149
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
150
159
 
151
160
  # Render as LLM-consumable XML context.
152
161
  #
153
- # Follows the format from docs/CONTEXT_AND_CHUNKING.md.
162
+ # Follows the format from `git log --follow -- docs/design/CONTEXT_AND_CHUNKING.md`.
154
163
  #
155
164
  # @return [String]
156
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
165
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
157
166
  def to_context
158
167
  lines = []
159
- header = "<session_context session_id=\"#{@session_id}\" requests=\"#{@steps.size}\" " \
168
+ header = "<session_context session_id=\"#{escape_attribute(@session_id)}\" requests=\"#{@steps.size}\" " \
160
169
  "tokens=\"#{@token_count}\" units=\"#{@context_pool.size}\">"
161
170
  lines << header
162
171
 
@@ -175,8 +184,10 @@ module Woods
175
184
  @context_pool.each do |identifier, unit|
176
185
  type = unit[:type] || 'unknown'
177
186
  file_path = unit[:file_path] || 'unknown'
178
- lines << %(<unit identifier="#{identifier}" type="#{type}" file="#{file_path}">)
179
- lines << (unit[:source_code] || '# source not available')
187
+ tag = %(<unit identifier="#{escape_attribute(identifier)}" type="#{escape_attribute(type)}" ) \
188
+ "file=\"#{escape_attribute(file_path)}\">"
189
+ lines << tag
190
+ lines << escape_content(unit[:source_code] || '# source not available')
180
191
  lines << '</unit>'
181
192
  end
182
193
 
@@ -201,7 +212,39 @@ module Woods
201
212
  lines << '</session_context>'
202
213
  lines.join("\n")
203
214
  end
204
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
215
+
216
+ private
217
+
218
+ # Escape a value for use inside a double-quoted attribute in
219
+ # {#to_context}'s markup.
220
+ #
221
+ # This is prompt-injection hardening, not XML conformance: `session_id`
222
+ # comes from the client-controlled X-Trace-Session header, and unit
223
+ # identifiers/paths flow through from extracted code. Unescaped, a
224
+ # crafted value (e.g. `"><injected_tag>`) breaks out of the attribute
225
+ # and inserts sibling markup into context an LLM later reads as trusted
226
+ # structure.
227
+ #
228
+ # @param value [Object]
229
+ # @return [String]
230
+ def escape_attribute(value)
231
+ value.to_s
232
+ .gsub('&', '&amp;')
233
+ .gsub('"', '&quot;')
234
+ .gsub('<', '&lt;')
235
+ .gsub('>', '&gt;')
236
+ end
237
+
238
+ # Escape a value for use as element content (between tags) in
239
+ # {#to_context}'s markup. Same prompt-injection motivation as
240
+ # {#escape_attribute}; content only needs `&` and `<` escaped since
241
+ # there's no attribute quote to break out of.
242
+ #
243
+ # @param value [Object]
244
+ # @return [String]
245
+ def escape_content(value)
246
+ value.to_s.gsub('&', '&amp;').gsub('<', '&lt;')
247
+ end
205
248
 
206
249
  # @api private
207
250
  def self.deep_symbolize_keys(obj)
@@ -218,6 +261,5 @@ module Woods
218
261
  end
219
262
  private_class_method :deep_symbolize_keys
220
263
  end
221
- # rubocop:enable Metrics/ClassLength
222
264
  end
223
265
  end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/cache'
4
+ require 'securerandom'
5
+
6
+ module Woods
7
+ class Error < StandardError; end unless defined?(Woods::Error)
8
+
9
+ module SessionTracer
10
+ # Uncached, per-key routed coordination operations for Solid Cache 1.0.
11
+ class SolidCacheCoordination # rubocop:disable Metrics/ClassLength
12
+ FAILURE = Object.new.freeze
13
+
14
+ class BackendError < Woods::Error; end
15
+
16
+ def initialize(cache)
17
+ @cache = cache
18
+ end
19
+
20
+ def read(name, options = nil)
21
+ with_operation(:read, name, options) do |key, merged_options, event|
22
+ raw = routed_read(key, :read_entry)
23
+ entry = @cache.send(:deserialize_entry, raw, **merged_options)
24
+ if entry&.expired?
25
+ delete_expired_entry(key, raw)
26
+ entry = nil
27
+ end
28
+ event[:hit] = !entry.nil? if event
29
+ entry&.value
30
+ end
31
+ rescue ActiveSupport::Cache::DeserializationError
32
+ nil
33
+ end
34
+
35
+ def write(name, value, **options)
36
+ with_operation(:write, name, options) do |key, merged_options, _event|
37
+ entry = cache_entry(name, value, merged_options)
38
+ payload = @cache.send(:serialize_entry, entry, **merged_options)
39
+ result = @cache.send(:entry_write, key, payload)
40
+ backend_result!(result, :write, name, nil_is_failure: true)
41
+ end
42
+ end
43
+
44
+ def delete(name, options = nil)
45
+ with_operation(:delete, name, options) do |key, _merged_options, _event|
46
+ routed_delete(key, :delete_entry).positive?
47
+ end
48
+ end
49
+
50
+ def increment(name, amount = 1, options = nil)
51
+ with_operation(:increment, name, options, amount: amount) do |key, merged_options, _event|
52
+ payload = @cache.send(:entry_lock_and_write, key) do |raw|
53
+ entry = @cache.send(:adjusted_entry, raw, amount, merged_options)
54
+ @cache.send(:serialize_entry, entry, **merged_options)
55
+ end
56
+ backend_result!(payload, :increment, name, nil_is_failure: true)
57
+ @cache.send(:deserialize_entry, payload, **merged_options).value
58
+ end
59
+ end
60
+
61
+ def write_if_absent(name, value, **options)
62
+ with_operation(:write, name, options) do |key, merged_options, _event|
63
+ entry = cache_entry(name, value, merged_options, version: "woods-cas:#{SecureRandom.hex(16)}")
64
+ payload = @cache.send(:serialize_entry, entry, **merged_options)
65
+ result = routed_write(key, :write_entry) do
66
+ entry_class = ::SolidCache::Entry
67
+ inserted = attempt_insert?(entry_class, key, payload)
68
+ if !inserted && clear_expired_entry?(entry_class, key, merged_options)
69
+ inserted = attempt_insert?(entry_class, key, payload)
70
+ end
71
+ @cache.send(:track_writes, 1) if inserted
72
+ inserted
73
+ end
74
+ backend_result!(result, :write_if_absent, name)
75
+ end
76
+ end
77
+
78
+ def delete_if_equal(name, expected)
79
+ with_operation(:delete, name, nil) do |key, merged_options, _event|
80
+ result = routed_write(key, :delete_entry) do
81
+ locked_compare_and_delete(key) do |raw_value|
82
+ entry = @cache.send(:deserialize_entry, raw_value, **merged_options)
83
+ entry && !entry.expired? && entry.value == expected
84
+ end
85
+ end
86
+ backend_result!(result, :delete_if_equal, name)
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def with_operation(operation, name, options, event_options = nil)
93
+ merged_options = @cache.send(:merged_options, options)
94
+ key = @cache.send(:normalize_key, name, merged_options)
95
+ instrument_options = event_options || merged_options
96
+
97
+ @cache.send(:bypass_local_cache) do
98
+ @cache.send(:instrument, operation, key, instrument_options) do |event|
99
+ yield key, merged_options, event
100
+ end
101
+ end
102
+ rescue NoMethodError => e
103
+ raise BackendError,
104
+ "Solid Cache private API unavailable for #{operation} #{name.inspect} (#{e.message}); " \
105
+ 'this Solid Cache version is not supported by Woods session tracing'
106
+ end
107
+
108
+ # Row-locked compare-and-delete on the raw stored payload. The block
109
+ # decides against the exact bytes read under lock, so a concurrent
110
+ # replacement can never be deleted by mistake.
111
+ def locked_compare_and_delete(key)
112
+ entry_class = ::SolidCache::Entry
113
+ key_hash = entry_class.send(:key_hash_for, key)
114
+ entry_class.transaction do
115
+ raw_key, raw_value = entry_class.lock.where(key_hash: key_hash).pick(:key, :value)
116
+ next false unless raw_key == key
117
+ next false unless yield(raw_value)
118
+
119
+ entry_class.delete_by_key(key)
120
+ true
121
+ end
122
+ end
123
+
124
+ # Conditionally remove an expired entry observed by a read: only the
125
+ # exact expired payload is deleted, never a fresh replacement.
126
+ def delete_expired_entry(key, observed_raw)
127
+ routed_write(key, :delete_entry) do
128
+ locked_compare_and_delete(key) { |raw_value| raw_value == observed_raw }
129
+ end
130
+ end
131
+
132
+ def attempt_insert?(entry_class, key, payload)
133
+ inserted?(entry_class, insert_entry(entry_class, key, payload), key, payload)
134
+ end
135
+
136
+ # Treat a physically-present but logically-expired row as absent for
137
+ # conditional writes by conditionally removing the exact expired payload.
138
+ def clear_expired_entry?(entry_class, key, merged_options)
139
+ raw = entry_class.read(key)
140
+ return false unless raw
141
+
142
+ entry = @cache.send(:deserialize_entry, raw, **merged_options)
143
+ return false unless entry&.expired?
144
+
145
+ locked_compare_and_delete(key) { |raw_value| raw_value == raw }
146
+ rescue ActiveSupport::Cache::DeserializationError
147
+ false
148
+ end
149
+
150
+ def cache_entry(name, value, options, version: @cache.send(:normalize_version, name, options))
151
+ ActiveSupport::Cache::Entry.new(value, **options, version: version)
152
+ end
153
+
154
+ def insert_entry(entry_class, key, payload)
155
+ attributes = entry_class.send(:add_key_hash_and_byte_size, [{ key: key, value: payload }])
156
+ entry_class.insert_all(attributes, unique_by: entry_class.send(:upsert_unique_by))
157
+ end
158
+
159
+ def inserted?(entry_class, result, key, payload)
160
+ affected_rows = result.affected_rows if result.respond_to?(:affected_rows)
161
+ return affected_rows.positive? unless affected_rows.nil?
162
+ return result.rows.any? if entry_class.connection.supports_insert_returning?
163
+
164
+ entry_class.read(key) == payload
165
+ end
166
+
167
+ def routed_read(key, failsafe)
168
+ result = @cache.send(:reading_key, key, failsafe: failsafe, failsafe_returning: FAILURE) do
169
+ ::SolidCache::Entry.read(key)
170
+ end
171
+ backend_result!(result, :read, key)
172
+ end
173
+
174
+ def routed_write(key, failsafe, &block)
175
+ @cache.send(:writing_key, key, failsafe: failsafe, failsafe_returning: FAILURE, &block)
176
+ end
177
+
178
+ def routed_delete(key, failsafe)
179
+ result = routed_write(key, failsafe) { ::SolidCache::Entry.delete_by_key(key) }
180
+ backend_result!(result, :delete, key)
181
+ end
182
+
183
+ def backend_result!(result, operation, key, nil_is_failure: false)
184
+ failed = result.equal?(FAILURE) || (nil_is_failure && result.nil?)
185
+ return result unless failed
186
+
187
+ raise BackendError,
188
+ "Solid Cache routed #{operation} failed for #{key.inspect}; inspect the cache backend error handler"
189
+ end
190
+ end
191
+ end
192
+ end