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
|
@@ -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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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('&', '&')
|
|
233
|
+
.gsub('"', '"')
|
|
234
|
+
.gsub('<', '<')
|
|
235
|
+
.gsub('>', '>')
|
|
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('&', '&').gsub('<', '<')
|
|
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
|