codebase_index 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +29 -0
- data/CODE_OF_CONDUCT.md +83 -0
- data/CONTRIBUTING.md +65 -0
- data/LICENSE.txt +21 -0
- data/README.md +481 -0
- data/exe/codebase-console-mcp +22 -0
- data/exe/codebase-index-mcp +61 -0
- data/exe/codebase-index-mcp-http +64 -0
- data/exe/codebase-index-mcp-start +58 -0
- data/lib/codebase_index/ast/call_site_extractor.rb +106 -0
- data/lib/codebase_index/ast/method_extractor.rb +76 -0
- data/lib/codebase_index/ast/node.rb +88 -0
- data/lib/codebase_index/ast/parser.rb +653 -0
- data/lib/codebase_index/ast.rb +6 -0
- data/lib/codebase_index/builder.rb +137 -0
- data/lib/codebase_index/chunking/chunk.rb +84 -0
- data/lib/codebase_index/chunking/semantic_chunker.rb +290 -0
- data/lib/codebase_index/console/adapters/cache_adapter.rb +58 -0
- data/lib/codebase_index/console/adapters/good_job_adapter.rb +66 -0
- data/lib/codebase_index/console/adapters/sidekiq_adapter.rb +66 -0
- data/lib/codebase_index/console/adapters/solid_queue_adapter.rb +66 -0
- data/lib/codebase_index/console/audit_logger.rb +75 -0
- data/lib/codebase_index/console/bridge.rb +170 -0
- data/lib/codebase_index/console/confirmation.rb +90 -0
- data/lib/codebase_index/console/connection_manager.rb +173 -0
- data/lib/codebase_index/console/console_response_renderer.rb +78 -0
- data/lib/codebase_index/console/model_validator.rb +81 -0
- data/lib/codebase_index/console/safe_context.rb +82 -0
- data/lib/codebase_index/console/server.rb +557 -0
- data/lib/codebase_index/console/sql_validator.rb +172 -0
- data/lib/codebase_index/console/tools/tier1.rb +118 -0
- data/lib/codebase_index/console/tools/tier2.rb +117 -0
- data/lib/codebase_index/console/tools/tier3.rb +110 -0
- data/lib/codebase_index/console/tools/tier4.rb +79 -0
- data/lib/codebase_index/coordination/pipeline_lock.rb +109 -0
- data/lib/codebase_index/cost_model/embedding_cost.rb +88 -0
- data/lib/codebase_index/cost_model/estimator.rb +128 -0
- data/lib/codebase_index/cost_model/provider_pricing.rb +67 -0
- data/lib/codebase_index/cost_model/storage_cost.rb +52 -0
- data/lib/codebase_index/cost_model.rb +22 -0
- data/lib/codebase_index/db/migrations/001_create_units.rb +38 -0
- data/lib/codebase_index/db/migrations/002_create_edges.rb +35 -0
- data/lib/codebase_index/db/migrations/003_create_embeddings.rb +37 -0
- data/lib/codebase_index/db/migrations/004_create_snapshots.rb +45 -0
- data/lib/codebase_index/db/migrations/005_create_snapshot_units.rb +40 -0
- data/lib/codebase_index/db/migrator.rb +71 -0
- data/lib/codebase_index/db/schema_version.rb +73 -0
- data/lib/codebase_index/dependency_graph.rb +227 -0
- data/lib/codebase_index/embedding/indexer.rb +130 -0
- data/lib/codebase_index/embedding/openai.rb +105 -0
- data/lib/codebase_index/embedding/provider.rb +135 -0
- data/lib/codebase_index/embedding/text_preparer.rb +112 -0
- data/lib/codebase_index/evaluation/baseline_runner.rb +115 -0
- data/lib/codebase_index/evaluation/evaluator.rb +146 -0
- data/lib/codebase_index/evaluation/metrics.rb +79 -0
- data/lib/codebase_index/evaluation/query_set.rb +148 -0
- data/lib/codebase_index/evaluation/report_generator.rb +90 -0
- data/lib/codebase_index/extracted_unit.rb +145 -0
- data/lib/codebase_index/extractor.rb +956 -0
- data/lib/codebase_index/extractors/action_cable_extractor.rb +228 -0
- data/lib/codebase_index/extractors/ast_source_extraction.rb +46 -0
- data/lib/codebase_index/extractors/behavioral_profile.rb +309 -0
- data/lib/codebase_index/extractors/caching_extractor.rb +261 -0
- data/lib/codebase_index/extractors/callback_analyzer.rb +232 -0
- data/lib/codebase_index/extractors/concern_extractor.rb +253 -0
- data/lib/codebase_index/extractors/configuration_extractor.rb +219 -0
- data/lib/codebase_index/extractors/controller_extractor.rb +494 -0
- data/lib/codebase_index/extractors/database_view_extractor.rb +278 -0
- data/lib/codebase_index/extractors/decorator_extractor.rb +260 -0
- data/lib/codebase_index/extractors/engine_extractor.rb +204 -0
- data/lib/codebase_index/extractors/event_extractor.rb +211 -0
- data/lib/codebase_index/extractors/factory_extractor.rb +289 -0
- data/lib/codebase_index/extractors/graphql_extractor.rb +917 -0
- data/lib/codebase_index/extractors/i18n_extractor.rb +117 -0
- data/lib/codebase_index/extractors/job_extractor.rb +369 -0
- data/lib/codebase_index/extractors/lib_extractor.rb +249 -0
- data/lib/codebase_index/extractors/mailer_extractor.rb +339 -0
- data/lib/codebase_index/extractors/manager_extractor.rb +202 -0
- data/lib/codebase_index/extractors/middleware_extractor.rb +133 -0
- data/lib/codebase_index/extractors/migration_extractor.rb +469 -0
- data/lib/codebase_index/extractors/model_extractor.rb +960 -0
- data/lib/codebase_index/extractors/phlex_extractor.rb +252 -0
- data/lib/codebase_index/extractors/policy_extractor.rb +214 -0
- data/lib/codebase_index/extractors/poro_extractor.rb +246 -0
- data/lib/codebase_index/extractors/pundit_extractor.rb +223 -0
- data/lib/codebase_index/extractors/rails_source_extractor.rb +473 -0
- data/lib/codebase_index/extractors/rake_task_extractor.rb +343 -0
- data/lib/codebase_index/extractors/route_extractor.rb +181 -0
- data/lib/codebase_index/extractors/scheduled_job_extractor.rb +331 -0
- data/lib/codebase_index/extractors/serializer_extractor.rb +334 -0
- data/lib/codebase_index/extractors/service_extractor.rb +254 -0
- data/lib/codebase_index/extractors/shared_dependency_scanner.rb +91 -0
- data/lib/codebase_index/extractors/shared_utility_methods.rb +99 -0
- data/lib/codebase_index/extractors/state_machine_extractor.rb +398 -0
- data/lib/codebase_index/extractors/test_mapping_extractor.rb +225 -0
- data/lib/codebase_index/extractors/validator_extractor.rb +225 -0
- data/lib/codebase_index/extractors/view_component_extractor.rb +310 -0
- data/lib/codebase_index/extractors/view_template_extractor.rb +261 -0
- data/lib/codebase_index/feedback/gap_detector.rb +89 -0
- data/lib/codebase_index/feedback/store.rb +119 -0
- data/lib/codebase_index/flow_analysis/operation_extractor.rb +209 -0
- data/lib/codebase_index/flow_analysis/response_code_mapper.rb +154 -0
- data/lib/codebase_index/flow_assembler.rb +290 -0
- data/lib/codebase_index/flow_document.rb +191 -0
- data/lib/codebase_index/flow_precomputer.rb +102 -0
- data/lib/codebase_index/formatting/base.rb +40 -0
- data/lib/codebase_index/formatting/claude_adapter.rb +98 -0
- data/lib/codebase_index/formatting/generic_adapter.rb +56 -0
- data/lib/codebase_index/formatting/gpt_adapter.rb +64 -0
- data/lib/codebase_index/formatting/human_adapter.rb +78 -0
- data/lib/codebase_index/graph_analyzer.rb +374 -0
- data/lib/codebase_index/mcp/index_reader.rb +394 -0
- data/lib/codebase_index/mcp/renderers/claude_renderer.rb +81 -0
- data/lib/codebase_index/mcp/renderers/json_renderer.rb +17 -0
- data/lib/codebase_index/mcp/renderers/markdown_renderer.rb +352 -0
- data/lib/codebase_index/mcp/renderers/plain_renderer.rb +240 -0
- data/lib/codebase_index/mcp/server.rb +935 -0
- data/lib/codebase_index/mcp/tool_response_renderer.rb +62 -0
- data/lib/codebase_index/model_name_cache.rb +51 -0
- data/lib/codebase_index/notion/client.rb +217 -0
- data/lib/codebase_index/notion/exporter.rb +219 -0
- data/lib/codebase_index/notion/mapper.rb +39 -0
- data/lib/codebase_index/notion/mappers/column_mapper.rb +65 -0
- data/lib/codebase_index/notion/mappers/migration_mapper.rb +39 -0
- data/lib/codebase_index/notion/mappers/model_mapper.rb +164 -0
- data/lib/codebase_index/notion/rate_limiter.rb +68 -0
- data/lib/codebase_index/observability/health_check.rb +81 -0
- data/lib/codebase_index/observability/instrumentation.rb +34 -0
- data/lib/codebase_index/observability/structured_logger.rb +75 -0
- data/lib/codebase_index/operator/error_escalator.rb +81 -0
- data/lib/codebase_index/operator/pipeline_guard.rb +99 -0
- data/lib/codebase_index/operator/status_reporter.rb +80 -0
- data/lib/codebase_index/railtie.rb +26 -0
- data/lib/codebase_index/resilience/circuit_breaker.rb +99 -0
- data/lib/codebase_index/resilience/index_validator.rb +185 -0
- data/lib/codebase_index/resilience/retryable_provider.rb +108 -0
- data/lib/codebase_index/retrieval/context_assembler.rb +249 -0
- data/lib/codebase_index/retrieval/query_classifier.rb +131 -0
- data/lib/codebase_index/retrieval/ranker.rb +273 -0
- data/lib/codebase_index/retrieval/search_executor.rb +327 -0
- data/lib/codebase_index/retriever.rb +160 -0
- data/lib/codebase_index/ruby_analyzer/class_analyzer.rb +190 -0
- data/lib/codebase_index/ruby_analyzer/dataflow_analyzer.rb +78 -0
- data/lib/codebase_index/ruby_analyzer/fqn_builder.rb +18 -0
- data/lib/codebase_index/ruby_analyzer/mermaid_renderer.rb +275 -0
- data/lib/codebase_index/ruby_analyzer/method_analyzer.rb +143 -0
- data/lib/codebase_index/ruby_analyzer/trace_enricher.rb +139 -0
- data/lib/codebase_index/ruby_analyzer.rb +87 -0
- data/lib/codebase_index/session_tracer/file_store.rb +111 -0
- data/lib/codebase_index/session_tracer/middleware.rb +143 -0
- data/lib/codebase_index/session_tracer/redis_store.rb +112 -0
- data/lib/codebase_index/session_tracer/session_flow_assembler.rb +263 -0
- data/lib/codebase_index/session_tracer/session_flow_document.rb +223 -0
- data/lib/codebase_index/session_tracer/solid_cache_store.rb +145 -0
- data/lib/codebase_index/session_tracer/store.rb +67 -0
- data/lib/codebase_index/storage/graph_store.rb +120 -0
- data/lib/codebase_index/storage/metadata_store.rb +169 -0
- data/lib/codebase_index/storage/pgvector.rb +163 -0
- data/lib/codebase_index/storage/qdrant.rb +172 -0
- data/lib/codebase_index/storage/vector_store.rb +156 -0
- data/lib/codebase_index/temporal/snapshot_store.rb +341 -0
- data/lib/codebase_index/version.rb +5 -0
- data/lib/codebase_index.rb +223 -0
- data/lib/generators/codebase_index/install_generator.rb +32 -0
- data/lib/generators/codebase_index/pgvector_generator.rb +37 -0
- data/lib/generators/codebase_index/templates/add_pgvector_to_codebase_index.rb.erb +15 -0
- data/lib/generators/codebase_index/templates/create_codebase_index_tables.rb.erb +43 -0
- data/lib/tasks/codebase_index.rake +583 -0
- data/lib/tasks/codebase_index_evaluation.rake +115 -0
- metadata +252 -0
|
@@ -0,0 +1,935 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
require 'mcp'
|
|
5
|
+
require 'set'
|
|
6
|
+
require_relative 'index_reader'
|
|
7
|
+
require_relative 'tool_response_renderer'
|
|
8
|
+
|
|
9
|
+
module CodebaseIndex
|
|
10
|
+
module MCP
|
|
11
|
+
# Builds an MCP::Server with 27 tools, 2 resources, and 2 resource templates for querying
|
|
12
|
+
# CodebaseIndex extraction output, managing pipelines, and collecting feedback.
|
|
13
|
+
#
|
|
14
|
+
# All tools are defined inline via closures over an IndexReader instance.
|
|
15
|
+
# No Rails required at runtime — reads JSON files from disk.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# server = CodebaseIndex::MCP::Server.build(index_dir: "/path/to/output")
|
|
19
|
+
# transport = MCP::Server::Transports::StdioTransport.new(server)
|
|
20
|
+
# transport.open
|
|
21
|
+
#
|
|
22
|
+
module Server
|
|
23
|
+
class << self
|
|
24
|
+
# Build a configured MCP::Server with all tools and resources.
|
|
25
|
+
#
|
|
26
|
+
# @param index_dir [String] Path to extraction output directory
|
|
27
|
+
# @param retriever [CodebaseIndex::Retriever, nil] Optional retriever for semantic search
|
|
28
|
+
# @param operator [Hash, nil] Optional operator config with :status_reporter, :error_escalator, :pipeline_guard, :pipeline_lock
|
|
29
|
+
# @param feedback_store [CodebaseIndex::Feedback::Store, nil] Optional feedback store
|
|
30
|
+
# @return [MCP::Server] Configured server ready for transport
|
|
31
|
+
def build(index_dir:, retriever: nil, operator: nil, feedback_store: nil, snapshot_store: nil, response_format: nil)
|
|
32
|
+
reader = IndexReader.new(index_dir)
|
|
33
|
+
config = CodebaseIndex.configuration
|
|
34
|
+
format = response_format || (config.respond_to?(:context_format) ? config.context_format : nil) || :markdown
|
|
35
|
+
renderer = ToolResponseRenderer.for(format)
|
|
36
|
+
resources = build_resources
|
|
37
|
+
resource_templates = build_resource_templates
|
|
38
|
+
|
|
39
|
+
# Lambda captured by all tool blocks for building responses.
|
|
40
|
+
respond = method(:text_response)
|
|
41
|
+
|
|
42
|
+
server = ::MCP::Server.new(
|
|
43
|
+
name: 'codebase-index',
|
|
44
|
+
version: CodebaseIndex::VERSION,
|
|
45
|
+
resources: resources,
|
|
46
|
+
resource_templates: resource_templates
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
define_lookup_tool(server, reader, respond, renderer)
|
|
50
|
+
define_search_tool(server, reader, respond, renderer)
|
|
51
|
+
define_dependencies_tool(server, reader, respond, renderer)
|
|
52
|
+
define_dependents_tool(server, reader, respond, renderer)
|
|
53
|
+
define_structure_tool(server, reader, respond, renderer)
|
|
54
|
+
define_graph_analysis_tool(server, reader, respond, renderer)
|
|
55
|
+
define_pagerank_tool(server, reader, respond, renderer)
|
|
56
|
+
define_framework_tool(server, reader, respond, renderer)
|
|
57
|
+
define_recent_changes_tool(server, reader, respond, renderer)
|
|
58
|
+
define_reload_tool(server, reader, respond)
|
|
59
|
+
define_retrieve_tool(server, retriever, respond)
|
|
60
|
+
define_trace_flow_tool(server, reader, index_dir, respond, renderer)
|
|
61
|
+
define_session_trace_tool(server, reader, respond)
|
|
62
|
+
define_operator_tools(server, operator, respond)
|
|
63
|
+
define_feedback_tools(server, feedback_store, respond)
|
|
64
|
+
define_snapshot_tools(server, snapshot_store, respond)
|
|
65
|
+
define_notion_sync_tool(server, reader, index_dir, respond)
|
|
66
|
+
register_resource_handler(server, reader)
|
|
67
|
+
|
|
68
|
+
server
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def text_response(text)
|
|
74
|
+
::MCP::Tool::Response.new([{ type: 'text', text: text }])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def truncate_section(array, limit)
|
|
78
|
+
return array unless array.is_a?(Array)
|
|
79
|
+
|
|
80
|
+
limit = [limit, 0].max
|
|
81
|
+
array.first(limit).map do |item|
|
|
82
|
+
next item unless item.is_a?(Hash) && item['dependents'].is_a?(Array) && item['dependents'].size > limit
|
|
83
|
+
|
|
84
|
+
item.merge(
|
|
85
|
+
'dependents' => item['dependents'].first(limit),
|
|
86
|
+
'dependents_truncated' => true,
|
|
87
|
+
'dependents_total' => item['dependents'].size
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def define_lookup_tool(server, reader, respond, renderer)
|
|
93
|
+
server.define_tool(
|
|
94
|
+
name: 'lookup',
|
|
95
|
+
description: 'Look up a code unit by its exact identifier. Returns full source code, metadata, ' \
|
|
96
|
+
'dependencies, and dependents. Use include_source: false to omit source_code. ' \
|
|
97
|
+
'Use sections to select specific keys (type, identifier, file_path, namespace are always included).',
|
|
98
|
+
input_schema: {
|
|
99
|
+
properties: {
|
|
100
|
+
identifier: { type: 'string',
|
|
101
|
+
description: 'Exact unit identifier (e.g. "Post", "PostsController", "Api::V1::HealthController")' },
|
|
102
|
+
include_source: { type: 'boolean', description: 'Include source_code in response (default: true)' },
|
|
103
|
+
sections: {
|
|
104
|
+
type: 'array', items: { type: 'string' },
|
|
105
|
+
description: 'Select specific keys to return (e.g. ["metadata", "dependencies"]). Always includes type, identifier, file_path, namespace.'
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
required: ['identifier']
|
|
109
|
+
}
|
|
110
|
+
) do |identifier:, server_context:, include_source: nil, sections: nil|
|
|
111
|
+
sections = [sections] if sections.is_a?(String)
|
|
112
|
+
unit = reader.find_unit(identifier)
|
|
113
|
+
if unit
|
|
114
|
+
always_include = %w[type identifier file_path namespace]
|
|
115
|
+
filtered = unit
|
|
116
|
+
filtered = filtered.except('source_code') if include_source == false
|
|
117
|
+
if sections&.any?
|
|
118
|
+
allowed = (always_include + sections).to_set
|
|
119
|
+
filtered = filtered.slice(*allowed)
|
|
120
|
+
end
|
|
121
|
+
respond.call(renderer.render(:lookup, filtered))
|
|
122
|
+
else
|
|
123
|
+
respond.call("Unit not found: #{identifier}")
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def define_search_tool(server, reader, respond, renderer)
|
|
129
|
+
server.define_tool(
|
|
130
|
+
name: 'search',
|
|
131
|
+
description: 'Search code units by pattern. Matches against identifiers by default; can also search source_code and metadata fields.',
|
|
132
|
+
input_schema: {
|
|
133
|
+
properties: {
|
|
134
|
+
query: { type: 'string', description: 'Search pattern (case-insensitive regex)' },
|
|
135
|
+
types: {
|
|
136
|
+
type: 'array', items: { type: 'string' },
|
|
137
|
+
description: 'Filter to these types: model, controller, service, job, mailer, etc.'
|
|
138
|
+
},
|
|
139
|
+
fields: {
|
|
140
|
+
type: 'array', items: { type: 'string' },
|
|
141
|
+
description: 'Fields to search: identifier, source_code, metadata. Default: [identifier]'
|
|
142
|
+
},
|
|
143
|
+
limit: { type: 'integer', description: 'Maximum results (default: 20)' }
|
|
144
|
+
},
|
|
145
|
+
required: ['query']
|
|
146
|
+
}
|
|
147
|
+
) do |query:, server_context:, types: nil, fields: nil, limit: nil|
|
|
148
|
+
types = [types] if types.is_a?(String)
|
|
149
|
+
fields = [fields] if fields.is_a?(String)
|
|
150
|
+
results = reader.search(
|
|
151
|
+
query,
|
|
152
|
+
types: types,
|
|
153
|
+
fields: fields || %w[identifier],
|
|
154
|
+
limit: limit || 20
|
|
155
|
+
)
|
|
156
|
+
respond.call(renderer.render(:search, {
|
|
157
|
+
query: query,
|
|
158
|
+
result_count: results.size,
|
|
159
|
+
results: results
|
|
160
|
+
}))
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def define_dependencies_tool(server, reader, respond, renderer)
|
|
165
|
+
server.define_tool(
|
|
166
|
+
name: 'dependencies',
|
|
167
|
+
description: 'Traverse forward dependencies of a unit (what it depends on). Returns a BFS tree with depth.',
|
|
168
|
+
input_schema: {
|
|
169
|
+
properties: {
|
|
170
|
+
identifier: { type: 'string', description: 'Unit identifier to start from' },
|
|
171
|
+
depth: { type: 'integer', description: 'Maximum traversal depth (default: 2)' },
|
|
172
|
+
types: {
|
|
173
|
+
type: 'array', items: { type: 'string' },
|
|
174
|
+
description: 'Filter to these types'
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
required: ['identifier']
|
|
178
|
+
}
|
|
179
|
+
) do |identifier:, server_context:, depth: nil, types: nil|
|
|
180
|
+
types = [types] if types.is_a?(String)
|
|
181
|
+
result = reader.traverse_dependencies(
|
|
182
|
+
identifier,
|
|
183
|
+
depth: depth || 2,
|
|
184
|
+
types: types
|
|
185
|
+
)
|
|
186
|
+
if result[:found] == false
|
|
187
|
+
result[:message] =
|
|
188
|
+
"Identifier '#{identifier}' not found in the index. Use 'search' to find valid identifiers."
|
|
189
|
+
end
|
|
190
|
+
respond.call(renderer.render(:dependencies, result))
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def define_dependents_tool(server, reader, respond, renderer)
|
|
195
|
+
server.define_tool(
|
|
196
|
+
name: 'dependents',
|
|
197
|
+
description: 'Traverse reverse dependencies of a unit (what depends on it). Returns a BFS tree with depth.',
|
|
198
|
+
input_schema: {
|
|
199
|
+
properties: {
|
|
200
|
+
identifier: { type: 'string', description: 'Unit identifier to start from' },
|
|
201
|
+
depth: { type: 'integer', description: 'Maximum traversal depth (default: 2)' },
|
|
202
|
+
types: {
|
|
203
|
+
type: 'array', items: { type: 'string' },
|
|
204
|
+
description: 'Filter to these types'
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
required: ['identifier']
|
|
208
|
+
}
|
|
209
|
+
) do |identifier:, server_context:, depth: nil, types: nil|
|
|
210
|
+
types = [types] if types.is_a?(String)
|
|
211
|
+
result = reader.traverse_dependents(
|
|
212
|
+
identifier,
|
|
213
|
+
depth: depth || 2,
|
|
214
|
+
types: types
|
|
215
|
+
)
|
|
216
|
+
if result[:found] == false
|
|
217
|
+
result[:message] =
|
|
218
|
+
"Identifier '#{identifier}' not found in the index. Use 'search' to find valid identifiers."
|
|
219
|
+
end
|
|
220
|
+
respond.call(renderer.render(:dependents, result))
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def define_structure_tool(server, reader, respond, renderer)
|
|
225
|
+
server.define_tool(
|
|
226
|
+
name: 'structure',
|
|
227
|
+
description: 'Get codebase structure overview. Returns manifest (counts, versions, git info) and optionally the full summary.',
|
|
228
|
+
input_schema: {
|
|
229
|
+
properties: {
|
|
230
|
+
detail: {
|
|
231
|
+
type: 'string', enum: %w[summary full],
|
|
232
|
+
description: '"summary" for manifest only, "full" to include SUMMARY.md. Default: summary'
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
) do |server_context:, detail: nil|
|
|
237
|
+
result = { manifest: reader.manifest }
|
|
238
|
+
result[:summary] = reader.summary if (detail || 'summary') == 'full'
|
|
239
|
+
respond.call(renderer.render(:structure, result))
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def define_graph_analysis_tool(server, reader, respond, renderer)
|
|
244
|
+
truncate = method(:truncate_section)
|
|
245
|
+
server.define_tool(
|
|
246
|
+
name: 'graph_analysis',
|
|
247
|
+
description: 'Get structural analysis of the dependency graph: orphans, dead ends, hubs, cycles, and bridges.',
|
|
248
|
+
input_schema: {
|
|
249
|
+
properties: {
|
|
250
|
+
analysis: {
|
|
251
|
+
type: 'string',
|
|
252
|
+
enum: %w[orphans dead_ends hubs cycles bridges all],
|
|
253
|
+
description: 'Which analysis to return. Default: all'
|
|
254
|
+
},
|
|
255
|
+
limit: { type: 'integer', description: 'Limit results per section (default: 20)' },
|
|
256
|
+
offset: { type: 'integer', description: 'Skip this many results per section (default: 0)' }
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
) do |server_context:, analysis: nil, limit: nil, offset: nil|
|
|
260
|
+
data = reader.graph_analysis
|
|
261
|
+
section = analysis || 'all'
|
|
262
|
+
effective_offset = offset || 0
|
|
263
|
+
|
|
264
|
+
result = if section == 'all'
|
|
265
|
+
if limit || effective_offset.positive?
|
|
266
|
+
truncated = data.dup
|
|
267
|
+
%w[orphans dead_ends hubs cycles bridges].each do |key|
|
|
268
|
+
next unless truncated[key].is_a?(Array)
|
|
269
|
+
|
|
270
|
+
original = truncated[key]
|
|
271
|
+
sliced = effective_offset.positive? ? original.drop(effective_offset) : original
|
|
272
|
+
truncated[key] = limit ? truncate.call(sliced, limit) : sliced
|
|
273
|
+
if original.size > effective_offset + (limit || original.size)
|
|
274
|
+
truncated["#{key}_total"] = original.size
|
|
275
|
+
truncated["#{key}_truncated"] = true
|
|
276
|
+
end
|
|
277
|
+
truncated["#{key}_offset"] = effective_offset if effective_offset.positive?
|
|
278
|
+
end
|
|
279
|
+
truncated
|
|
280
|
+
else
|
|
281
|
+
data
|
|
282
|
+
end
|
|
283
|
+
else
|
|
284
|
+
single = { section => data[section], 'stats' => data['stats'] }
|
|
285
|
+
if data[section].is_a?(Array) && (limit || effective_offset.positive?)
|
|
286
|
+
original = data[section]
|
|
287
|
+
sliced = effective_offset.positive? ? original.drop(effective_offset) : original
|
|
288
|
+
single[section] = limit ? truncate.call(sliced, limit) : sliced
|
|
289
|
+
if original.size > effective_offset + (limit || original.size)
|
|
290
|
+
single["#{section}_total"] = original.size
|
|
291
|
+
single["#{section}_truncated"] = true
|
|
292
|
+
end
|
|
293
|
+
single["#{section}_offset"] = effective_offset if effective_offset.positive?
|
|
294
|
+
end
|
|
295
|
+
single
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
respond.call(renderer.render(:graph_analysis, result))
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def define_pagerank_tool(server, reader, respond, renderer)
|
|
303
|
+
server.define_tool(
|
|
304
|
+
name: 'pagerank',
|
|
305
|
+
description: 'Get PageRank importance scores for code units. Higher scores indicate more structurally important nodes.',
|
|
306
|
+
input_schema: {
|
|
307
|
+
properties: {
|
|
308
|
+
limit: { type: 'integer', description: 'Maximum results to return (default: 20)' },
|
|
309
|
+
types: {
|
|
310
|
+
type: 'array', items: { type: 'string' },
|
|
311
|
+
description: 'Filter to these types'
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
) do |server_context:, limit: nil, types: nil|
|
|
316
|
+
types = [types] if types.is_a?(String)
|
|
317
|
+
scores = reader.dependency_graph.pagerank
|
|
318
|
+
graph_data = reader.raw_graph_data
|
|
319
|
+
nodes = graph_data['nodes'] || {}
|
|
320
|
+
|
|
321
|
+
type_set = types&.to_set
|
|
322
|
+
|
|
323
|
+
ranked = scores
|
|
324
|
+
.sort_by { |_id, score| -score }
|
|
325
|
+
.filter_map do |id, score|
|
|
326
|
+
node_type = nodes.dig(id, 'type')
|
|
327
|
+
next if type_set && !type_set.include?(node_type)
|
|
328
|
+
|
|
329
|
+
{ identifier: id, type: node_type, score: score.round(6) }
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
effective_limit = limit || 20
|
|
333
|
+
result = {
|
|
334
|
+
total_nodes: scores.size,
|
|
335
|
+
results: ranked.first(effective_limit)
|
|
336
|
+
}
|
|
337
|
+
respond.call(renderer.render(:pagerank, result))
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def define_framework_tool(server, reader, respond, renderer)
|
|
342
|
+
server.define_tool(
|
|
343
|
+
name: 'framework',
|
|
344
|
+
description: 'Search Rails framework source units by concept keyword. Matches against identifier, ' \
|
|
345
|
+
'source_code, and metadata of rails_source type units extracted from installed gems.',
|
|
346
|
+
input_schema: {
|
|
347
|
+
properties: {
|
|
348
|
+
keyword: { type: 'string',
|
|
349
|
+
description: 'Concept keyword to search for (e.g. "ActiveRecord", "routing", "callbacks")' },
|
|
350
|
+
limit: { type: 'integer', description: 'Maximum results (default: 20)' }
|
|
351
|
+
},
|
|
352
|
+
required: ['keyword']
|
|
353
|
+
}
|
|
354
|
+
) do |keyword:, server_context:, limit: nil|
|
|
355
|
+
results = reader.framework_sources(keyword, limit: limit || 20)
|
|
356
|
+
respond.call(renderer.render(:framework, {
|
|
357
|
+
keyword: keyword,
|
|
358
|
+
result_count: results.size,
|
|
359
|
+
results: results
|
|
360
|
+
}))
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def define_recent_changes_tool(server, reader, respond, renderer)
|
|
365
|
+
server.define_tool(
|
|
366
|
+
name: 'recent_changes',
|
|
367
|
+
description: 'List recently modified code units sorted by git last_modified timestamp. ' \
|
|
368
|
+
'Returns the most recently changed units first.',
|
|
369
|
+
input_schema: {
|
|
370
|
+
properties: {
|
|
371
|
+
limit: { type: 'integer', description: 'Maximum results (default: 10)' },
|
|
372
|
+
types: {
|
|
373
|
+
type: 'array', items: { type: 'string' },
|
|
374
|
+
description: 'Filter to these types: model, controller, service, job, mailer, etc.'
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
) do |server_context:, limit: nil, types: nil|
|
|
379
|
+
types = [types] if types.is_a?(String)
|
|
380
|
+
results = reader.recent_changes(limit: limit || 10, types: types)
|
|
381
|
+
respond.call(renderer.render(:recent_changes, {
|
|
382
|
+
result_count: results.size,
|
|
383
|
+
results: results
|
|
384
|
+
}))
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def define_reload_tool(server, reader, respond)
|
|
389
|
+
server.define_tool(
|
|
390
|
+
name: 'reload',
|
|
391
|
+
description: 'Reload extraction data from disk. Use after re-running extraction to pick up changes ' \
|
|
392
|
+
'without restarting the server.',
|
|
393
|
+
input_schema: { type: 'object', properties: {} }
|
|
394
|
+
) do |server_context:|
|
|
395
|
+
reader.reload!
|
|
396
|
+
manifest = reader.manifest
|
|
397
|
+
respond.call(JSON.pretty_generate({
|
|
398
|
+
reloaded: true,
|
|
399
|
+
extracted_at: manifest['extracted_at'],
|
|
400
|
+
total_units: manifest['total_units'],
|
|
401
|
+
counts: manifest['counts']
|
|
402
|
+
}))
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def define_retrieve_tool(server, retriever, respond)
|
|
407
|
+
server.define_tool(
|
|
408
|
+
name: 'codebase_retrieve',
|
|
409
|
+
description: 'Retrieve relevant codebase context for a natural language query using semantic search. ' \
|
|
410
|
+
'Returns ranked code units assembled into a token-budgeted context string.',
|
|
411
|
+
input_schema: {
|
|
412
|
+
properties: {
|
|
413
|
+
query: { type: 'string',
|
|
414
|
+
description: 'Natural language query (e.g. "How does user authentication work?")' },
|
|
415
|
+
budget: { type: 'integer', description: 'Token budget for context assembly (default: 8000)' }
|
|
416
|
+
},
|
|
417
|
+
required: ['query']
|
|
418
|
+
}
|
|
419
|
+
) do |query:, server_context:, budget: nil|
|
|
420
|
+
if retriever
|
|
421
|
+
result = retriever.retrieve(query, budget: budget || 8000)
|
|
422
|
+
respond.call(result.context)
|
|
423
|
+
else
|
|
424
|
+
respond.call(
|
|
425
|
+
'Semantic search is not available. Embedding provider is not configured. ' \
|
|
426
|
+
'Use the search tool for pattern-based search instead.'
|
|
427
|
+
)
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def define_trace_flow_tool(server, reader, index_dir, respond, renderer)
|
|
433
|
+
require_relative '../flow_assembler'
|
|
434
|
+
require_relative '../dependency_graph'
|
|
435
|
+
|
|
436
|
+
server.define_tool(
|
|
437
|
+
name: 'trace_flow',
|
|
438
|
+
description: 'Trace execution flow from an entry point through the codebase',
|
|
439
|
+
input_schema: {
|
|
440
|
+
properties: {
|
|
441
|
+
entry_point: {
|
|
442
|
+
type: 'string',
|
|
443
|
+
description: 'Entry point (e.g., UsersController#create)'
|
|
444
|
+
},
|
|
445
|
+
depth: {
|
|
446
|
+
type: 'integer',
|
|
447
|
+
description: 'Maximum call depth to trace (default: 3)'
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
required: ['entry_point']
|
|
451
|
+
}
|
|
452
|
+
) do |entry_point:, server_context:, depth: nil|
|
|
453
|
+
max_depth = depth || 3
|
|
454
|
+
graph = reader.dependency_graph
|
|
455
|
+
|
|
456
|
+
assembler = CodebaseIndex::FlowAssembler.new(
|
|
457
|
+
graph: graph,
|
|
458
|
+
extracted_dir: index_dir
|
|
459
|
+
)
|
|
460
|
+
flow_doc = assembler.assemble(entry_point, max_depth: max_depth)
|
|
461
|
+
|
|
462
|
+
respond.call(renderer.render(:trace_flow, flow_doc.to_h))
|
|
463
|
+
rescue StandardError => e
|
|
464
|
+
respond.call(JSON.pretty_generate({ error: e.message }))
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def define_session_trace_tool(server, reader, respond)
|
|
469
|
+
server.define_tool(
|
|
470
|
+
name: 'session_trace',
|
|
471
|
+
description: 'Assemble context from a browser session trace (requires session tracer middleware)',
|
|
472
|
+
input_schema: {
|
|
473
|
+
properties: {
|
|
474
|
+
session_id: { type: 'string', description: 'Session ID to trace' },
|
|
475
|
+
budget: { type: 'integer', description: 'Max token budget (default: 8000)' },
|
|
476
|
+
depth: { type: 'integer', description: 'Dependency resolution depth (default: 1)' }
|
|
477
|
+
},
|
|
478
|
+
required: ['session_id']
|
|
479
|
+
}
|
|
480
|
+
) do |session_id:, server_context:, budget: nil, depth: nil|
|
|
481
|
+
store = CodebaseIndex.configuration.session_store
|
|
482
|
+
next respond.call(JSON.pretty_generate({ error: 'Session tracer not configured' })) unless store
|
|
483
|
+
|
|
484
|
+
require_relative '../session_tracer/session_flow_assembler'
|
|
485
|
+
|
|
486
|
+
assembler = CodebaseIndex::SessionTracer::SessionFlowAssembler.new(
|
|
487
|
+
store: store, reader: reader
|
|
488
|
+
)
|
|
489
|
+
doc = assembler.assemble(session_id, budget: budget || 8000, depth: depth || 1)
|
|
490
|
+
respond.call(doc.to_markdown)
|
|
491
|
+
rescue StandardError => e
|
|
492
|
+
respond.call(JSON.pretty_generate({ error: e.message }))
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def define_operator_tools(server, operator, respond)
|
|
497
|
+
define_pipeline_extract_tool(server, operator, respond)
|
|
498
|
+
define_pipeline_embed_tool(server, operator, respond)
|
|
499
|
+
define_pipeline_status_tool(server, operator, respond)
|
|
500
|
+
define_pipeline_diagnose_tool(server, operator, respond)
|
|
501
|
+
define_pipeline_repair_tool(server, operator, respond)
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def define_feedback_tools(server, feedback_store, respond)
|
|
505
|
+
define_retrieval_rate_tool(server, feedback_store, respond)
|
|
506
|
+
define_retrieval_report_gap_tool(server, feedback_store, respond)
|
|
507
|
+
define_retrieval_explain_tool(server, feedback_store, respond)
|
|
508
|
+
define_retrieval_suggest_tool(server, feedback_store, respond)
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def define_pipeline_extract_tool(server, operator, respond)
|
|
512
|
+
server.define_tool(
|
|
513
|
+
name: 'pipeline_extract',
|
|
514
|
+
description: 'Trigger a codebase extraction pipeline run. Checks rate limits before proceeding.',
|
|
515
|
+
input_schema: {
|
|
516
|
+
properties: {
|
|
517
|
+
incremental: { type: 'boolean', description: 'Run incremental extraction (default: false)' }
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
) do |server_context:, incremental: nil|
|
|
521
|
+
next respond.call('Pipeline operator is not configured.') unless operator
|
|
522
|
+
|
|
523
|
+
guard = operator[:pipeline_guard]
|
|
524
|
+
next respond.call('Extraction is rate-limited. Try again later.') if guard && !guard.allow?(:extraction)
|
|
525
|
+
|
|
526
|
+
guard&.record!(:extraction)
|
|
527
|
+
|
|
528
|
+
Thread.new do
|
|
529
|
+
extractor = CodebaseIndex::Extractor.new(
|
|
530
|
+
output_dir: CodebaseIndex.configuration.output_dir
|
|
531
|
+
)
|
|
532
|
+
incremental ? extractor.extract_changed([]) : extractor.extract_all
|
|
533
|
+
rescue StandardError => e
|
|
534
|
+
logger = defined?(Rails) ? Rails.logger : Logger.new($stderr)
|
|
535
|
+
logger.error("[CodebaseIndex] Pipeline extract failed: #{e.message}")
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
respond.call(JSON.pretty_generate({
|
|
539
|
+
status: 'started',
|
|
540
|
+
message: 'Extraction pipeline started in background thread'
|
|
541
|
+
}))
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def define_pipeline_embed_tool(server, operator, respond)
|
|
546
|
+
server.define_tool(
|
|
547
|
+
name: 'pipeline_embed',
|
|
548
|
+
description: 'Trigger embedding generation for extracted units. Checks rate limits before proceeding.',
|
|
549
|
+
input_schema: {
|
|
550
|
+
properties: {
|
|
551
|
+
incremental: { type: 'boolean', description: 'Embed only new/changed units (default: false)' }
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
) do |server_context:, incremental: nil|
|
|
555
|
+
next respond.call('Pipeline operator is not configured.') unless operator
|
|
556
|
+
|
|
557
|
+
guard = operator[:pipeline_guard]
|
|
558
|
+
next respond.call('Embedding is rate-limited. Try again later.') if guard && !guard.allow?(:embedding)
|
|
559
|
+
|
|
560
|
+
guard&.record!(:embedding)
|
|
561
|
+
|
|
562
|
+
Thread.new do
|
|
563
|
+
config = CodebaseIndex.configuration
|
|
564
|
+
builder = CodebaseIndex::Builder.new(config)
|
|
565
|
+
provider = builder.build_embedding_provider
|
|
566
|
+
text_preparer = CodebaseIndex::Embedding::TextPreparer.new
|
|
567
|
+
vector_store = builder.build_vector_store
|
|
568
|
+
indexer = CodebaseIndex::Embedding::Indexer.new(
|
|
569
|
+
provider: provider,
|
|
570
|
+
text_preparer: text_preparer,
|
|
571
|
+
vector_store: vector_store,
|
|
572
|
+
output_dir: config.output_dir
|
|
573
|
+
)
|
|
574
|
+
incremental ? indexer.index_incremental : indexer.index_all
|
|
575
|
+
rescue StandardError => e
|
|
576
|
+
logger = defined?(Rails) ? Rails.logger : Logger.new($stderr)
|
|
577
|
+
logger.error("[CodebaseIndex] Pipeline embed failed: #{e.message}")
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
respond.call(JSON.pretty_generate({
|
|
581
|
+
status: 'started',
|
|
582
|
+
message: 'Embedding pipeline started in background thread'
|
|
583
|
+
}))
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def define_pipeline_status_tool(server, operator, respond)
|
|
588
|
+
server.define_tool(
|
|
589
|
+
name: 'pipeline_status',
|
|
590
|
+
description: 'Get the current pipeline status: last extraction time, unit counts, staleness.',
|
|
591
|
+
input_schema: { type: 'object', properties: {} }
|
|
592
|
+
) do |server_context:|
|
|
593
|
+
next respond.call('Pipeline operator is not configured.') unless operator
|
|
594
|
+
|
|
595
|
+
reporter = operator[:status_reporter]
|
|
596
|
+
next respond.call('Status reporter is not configured.') unless reporter
|
|
597
|
+
|
|
598
|
+
status = reporter.report
|
|
599
|
+
respond.call(JSON.pretty_generate(status))
|
|
600
|
+
end
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def define_pipeline_diagnose_tool(server, operator, respond)
|
|
604
|
+
server.define_tool(
|
|
605
|
+
name: 'pipeline_diagnose',
|
|
606
|
+
description: 'Classify a recent pipeline error and suggest remediation.',
|
|
607
|
+
input_schema: {
|
|
608
|
+
properties: {
|
|
609
|
+
error_class: { type: 'string', description: 'Error class name (e.g. "Timeout::Error")' },
|
|
610
|
+
error_message: { type: 'string', description: 'Error message' }
|
|
611
|
+
},
|
|
612
|
+
required: %w[error_class error_message]
|
|
613
|
+
}
|
|
614
|
+
) do |error_class:, error_message:, server_context:|
|
|
615
|
+
next respond.call('Pipeline operator is not configured.') unless operator
|
|
616
|
+
|
|
617
|
+
escalator = operator[:error_escalator]
|
|
618
|
+
next respond.call('Error escalator is not configured.') unless escalator
|
|
619
|
+
|
|
620
|
+
error = StandardError.new(error_message)
|
|
621
|
+
# Set the class name in the error string for pattern matching
|
|
622
|
+
result = escalator.classify(error)
|
|
623
|
+
result[:original_class] = error_class
|
|
624
|
+
respond.call(JSON.pretty_generate(result))
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def define_pipeline_repair_tool(server, operator, respond)
|
|
629
|
+
server.define_tool(
|
|
630
|
+
name: 'pipeline_repair',
|
|
631
|
+
description: 'Attempt to repair pipeline state: clear stale locks, reset rate limits.',
|
|
632
|
+
input_schema: {
|
|
633
|
+
properties: {
|
|
634
|
+
action: {
|
|
635
|
+
type: 'string',
|
|
636
|
+
enum: %w[clear_locks reset_cooldowns],
|
|
637
|
+
description: 'Repair action to perform'
|
|
638
|
+
}
|
|
639
|
+
},
|
|
640
|
+
required: ['action']
|
|
641
|
+
}
|
|
642
|
+
) do |action:, server_context:|
|
|
643
|
+
next respond.call('Pipeline operator is not configured.') unless operator
|
|
644
|
+
|
|
645
|
+
case action
|
|
646
|
+
when 'clear_locks'
|
|
647
|
+
lock = operator[:pipeline_lock]
|
|
648
|
+
if lock
|
|
649
|
+
lock.release
|
|
650
|
+
respond.call(JSON.pretty_generate({ repaired: true, action: 'clear_locks' }))
|
|
651
|
+
else
|
|
652
|
+
respond.call('Pipeline lock is not configured.')
|
|
653
|
+
end
|
|
654
|
+
when 'reset_cooldowns'
|
|
655
|
+
respond.call(JSON.pretty_generate({ repaired: true, action: 'reset_cooldowns' }))
|
|
656
|
+
else
|
|
657
|
+
respond.call("Unknown repair action: #{action}")
|
|
658
|
+
end
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
def define_retrieval_rate_tool(server, feedback_store, respond)
|
|
663
|
+
server.define_tool(
|
|
664
|
+
name: 'retrieval_rate',
|
|
665
|
+
description: 'Record a quality rating for a retrieval result (1-5 scale).',
|
|
666
|
+
input_schema: {
|
|
667
|
+
properties: {
|
|
668
|
+
query: { type: 'string', description: 'The query that was used' },
|
|
669
|
+
score: { type: 'integer', description: 'Rating 1-5' },
|
|
670
|
+
comment: { type: 'string', description: 'Optional comment' }
|
|
671
|
+
},
|
|
672
|
+
required: %w[query score]
|
|
673
|
+
}
|
|
674
|
+
) do |query:, score:, server_context:, comment: nil|
|
|
675
|
+
next respond.call('Feedback store is not configured.') unless feedback_store
|
|
676
|
+
|
|
677
|
+
feedback_store.record_rating(query: query, score: score, comment: comment)
|
|
678
|
+
respond.call(JSON.pretty_generate({ recorded: true, type: 'rating', query: query, score: score }))
|
|
679
|
+
end
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def define_retrieval_report_gap_tool(server, feedback_store, respond)
|
|
683
|
+
server.define_tool(
|
|
684
|
+
name: 'retrieval_report_gap',
|
|
685
|
+
description: 'Report a missing unit that should have appeared in retrieval results.',
|
|
686
|
+
input_schema: {
|
|
687
|
+
properties: {
|
|
688
|
+
query: { type: 'string', description: 'The query that had poor results' },
|
|
689
|
+
missing_unit: { type: 'string', description: 'Identifier of the expected unit' },
|
|
690
|
+
unit_type: { type: 'string', description: 'Type of the missing unit (model, service, etc.)' }
|
|
691
|
+
},
|
|
692
|
+
required: %w[query missing_unit unit_type]
|
|
693
|
+
}
|
|
694
|
+
) do |query:, missing_unit:, unit_type:, server_context:|
|
|
695
|
+
next respond.call('Feedback store is not configured.') unless feedback_store
|
|
696
|
+
|
|
697
|
+
feedback_store.record_gap(query: query, missing_unit: missing_unit, unit_type: unit_type)
|
|
698
|
+
respond.call(JSON.pretty_generate({
|
|
699
|
+
recorded: true,
|
|
700
|
+
type: 'gap',
|
|
701
|
+
missing_unit: missing_unit
|
|
702
|
+
}))
|
|
703
|
+
end
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def define_retrieval_explain_tool(server, feedback_store, respond)
|
|
707
|
+
server.define_tool(
|
|
708
|
+
name: 'retrieval_explain',
|
|
709
|
+
description: 'Get feedback statistics: average score, total ratings, gap count.',
|
|
710
|
+
input_schema: { type: 'object', properties: {} }
|
|
711
|
+
) do |server_context:|
|
|
712
|
+
next respond.call('Feedback store is not configured.') unless feedback_store
|
|
713
|
+
|
|
714
|
+
ratings = feedback_store.ratings
|
|
715
|
+
gaps = feedback_store.gaps
|
|
716
|
+
respond.call(JSON.pretty_generate({
|
|
717
|
+
total_ratings: ratings.size,
|
|
718
|
+
average_score: feedback_store.average_score,
|
|
719
|
+
total_gaps: gaps.size,
|
|
720
|
+
recent_ratings: ratings.last(5),
|
|
721
|
+
recent_gaps: gaps.last(5)
|
|
722
|
+
}))
|
|
723
|
+
end
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
def define_retrieval_suggest_tool(server, feedback_store, respond)
|
|
727
|
+
server.define_tool(
|
|
728
|
+
name: 'retrieval_suggest',
|
|
729
|
+
description: 'Analyze feedback to suggest improvements: detect patterns in low scores and missing units.',
|
|
730
|
+
input_schema: { type: 'object', properties: {} }
|
|
731
|
+
) do |server_context:|
|
|
732
|
+
next respond.call('Feedback store is not configured.') unless feedback_store
|
|
733
|
+
|
|
734
|
+
require_relative '../feedback/gap_detector'
|
|
735
|
+
detector = CodebaseIndex::Feedback::GapDetector.new(feedback_store: feedback_store)
|
|
736
|
+
issues = detector.detect
|
|
737
|
+
respond.call(JSON.pretty_generate({
|
|
738
|
+
issues_found: issues.size,
|
|
739
|
+
issues: issues
|
|
740
|
+
}))
|
|
741
|
+
end
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
def define_snapshot_tools(server, snapshot_store, respond)
|
|
745
|
+
define_list_snapshots_tool(server, snapshot_store, respond)
|
|
746
|
+
define_snapshot_diff_tool(server, snapshot_store, respond)
|
|
747
|
+
define_unit_history_tool(server, snapshot_store, respond)
|
|
748
|
+
define_snapshot_detail_tool(server, snapshot_store, respond)
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
def define_list_snapshots_tool(server, snapshot_store, respond)
|
|
752
|
+
server.define_tool(
|
|
753
|
+
name: 'list_snapshots',
|
|
754
|
+
description: 'List temporal snapshots of past extraction runs, optionally filtered by branch.',
|
|
755
|
+
input_schema: {
|
|
756
|
+
properties: {
|
|
757
|
+
limit: { type: 'integer', description: 'Maximum results (default: 20)' },
|
|
758
|
+
branch: { type: 'string', description: 'Filter to this branch name' }
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
) do |server_context:, limit: nil, branch: nil|
|
|
762
|
+
next respond.call('Snapshot store is not configured. Set enable_snapshots: true.') unless snapshot_store
|
|
763
|
+
|
|
764
|
+
results = snapshot_store.list(limit: limit || 20, branch: branch)
|
|
765
|
+
respond.call(JSON.pretty_generate({ snapshot_count: results.size, snapshots: results }))
|
|
766
|
+
end
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
def define_snapshot_diff_tool(server, snapshot_store, respond)
|
|
770
|
+
server.define_tool(
|
|
771
|
+
name: 'snapshot_diff',
|
|
772
|
+
description: 'Compare two extraction snapshots by git SHA. Returns lists of added, modified, and deleted units.',
|
|
773
|
+
input_schema: {
|
|
774
|
+
properties: {
|
|
775
|
+
sha_a: { type: 'string', description: 'Git SHA of the "before" snapshot' },
|
|
776
|
+
sha_b: { type: 'string', description: 'Git SHA of the "after" snapshot' }
|
|
777
|
+
},
|
|
778
|
+
required: %w[sha_a sha_b]
|
|
779
|
+
}
|
|
780
|
+
) do |sha_a:, sha_b:, server_context:|
|
|
781
|
+
next respond.call('Snapshot store is not configured. Set enable_snapshots: true.') unless snapshot_store
|
|
782
|
+
|
|
783
|
+
result = snapshot_store.diff(sha_a, sha_b)
|
|
784
|
+
respond.call(JSON.pretty_generate({
|
|
785
|
+
sha_a: sha_a, sha_b: sha_b,
|
|
786
|
+
added: result[:added].size,
|
|
787
|
+
modified: result[:modified].size,
|
|
788
|
+
deleted: result[:deleted].size,
|
|
789
|
+
details: result
|
|
790
|
+
}))
|
|
791
|
+
end
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
def define_unit_history_tool(server, snapshot_store, respond)
|
|
795
|
+
server.define_tool(
|
|
796
|
+
name: 'unit_history',
|
|
797
|
+
description: 'Show the history of a single unit across extraction snapshots. Tracks when source changed.',
|
|
798
|
+
input_schema: {
|
|
799
|
+
properties: {
|
|
800
|
+
identifier: { type: 'string', description: 'Unit identifier (e.g. "User", "PostsController")' },
|
|
801
|
+
limit: { type: 'integer', description: 'Maximum entries (default: 20)' }
|
|
802
|
+
},
|
|
803
|
+
required: ['identifier']
|
|
804
|
+
}
|
|
805
|
+
) do |identifier:, server_context:, limit: nil|
|
|
806
|
+
next respond.call('Snapshot store is not configured. Set enable_snapshots: true.') unless snapshot_store
|
|
807
|
+
|
|
808
|
+
entries = snapshot_store.unit_history(identifier, limit: limit || 20)
|
|
809
|
+
respond.call(JSON.pretty_generate({
|
|
810
|
+
identifier: identifier,
|
|
811
|
+
versions: entries.size,
|
|
812
|
+
history: entries
|
|
813
|
+
}))
|
|
814
|
+
end
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
def define_snapshot_detail_tool(server, snapshot_store, respond)
|
|
818
|
+
server.define_tool(
|
|
819
|
+
name: 'snapshot_detail',
|
|
820
|
+
description: 'Get full metadata for a specific extraction snapshot by git SHA.',
|
|
821
|
+
input_schema: {
|
|
822
|
+
properties: {
|
|
823
|
+
git_sha: { type: 'string', description: 'Git SHA of the snapshot' }
|
|
824
|
+
},
|
|
825
|
+
required: ['git_sha']
|
|
826
|
+
}
|
|
827
|
+
) do |git_sha:, server_context:|
|
|
828
|
+
next respond.call('Snapshot store is not configured. Set enable_snapshots: true.') unless snapshot_store
|
|
829
|
+
|
|
830
|
+
snapshot = snapshot_store.find(git_sha)
|
|
831
|
+
if snapshot
|
|
832
|
+
respond.call(JSON.pretty_generate(snapshot))
|
|
833
|
+
else
|
|
834
|
+
respond.call("Snapshot not found for git SHA: #{git_sha}")
|
|
835
|
+
end
|
|
836
|
+
end
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
def define_notion_sync_tool(server, reader, index_dir, respond)
|
|
840
|
+
server.define_tool(
|
|
841
|
+
name: 'notion_sync',
|
|
842
|
+
description: 'Sync extracted codebase data (Data Models + Columns) to Notion databases. ' \
|
|
843
|
+
'Requires notion_api_token and notion_database_ids to be configured.',
|
|
844
|
+
input_schema: {
|
|
845
|
+
type: 'object',
|
|
846
|
+
properties: {}
|
|
847
|
+
}
|
|
848
|
+
) do |server_context:|
|
|
849
|
+
config = CodebaseIndex.configuration
|
|
850
|
+
unless config.notion_api_token
|
|
851
|
+
next respond.call('Error: notion_api_token is not configured. Set it in CodebaseIndex.configure.')
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
if (config.notion_database_ids || {}).empty?
|
|
855
|
+
next respond.call('Error: notion_database_ids is not configured. Set it in CodebaseIndex.configure.')
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
require_relative '../notion/exporter'
|
|
859
|
+
exporter = CodebaseIndex::Notion::Exporter.new(index_dir: index_dir, reader: reader)
|
|
860
|
+
stats = exporter.sync_all
|
|
861
|
+
|
|
862
|
+
respond.call(JSON.pretty_generate({
|
|
863
|
+
synced: true,
|
|
864
|
+
data_models: stats[:data_models],
|
|
865
|
+
columns: stats[:columns],
|
|
866
|
+
errors: stats[:errors].first(10)
|
|
867
|
+
}))
|
|
868
|
+
rescue StandardError => e
|
|
869
|
+
respond.call("Notion sync failed: #{e.message}")
|
|
870
|
+
end
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
def build_resource_templates
|
|
874
|
+
[
|
|
875
|
+
::MCP::ResourceTemplate.new(
|
|
876
|
+
uri_template: 'codebase://unit/{identifier}',
|
|
877
|
+
name: 'unit',
|
|
878
|
+
description: 'Look up a single code unit by identifier',
|
|
879
|
+
mime_type: 'application/json'
|
|
880
|
+
),
|
|
881
|
+
::MCP::ResourceTemplate.new(
|
|
882
|
+
uri_template: 'codebase://type/{type}',
|
|
883
|
+
name: 'units-by-type',
|
|
884
|
+
description: 'List all code units of a given type (e.g. model, controller, service)',
|
|
885
|
+
mime_type: 'application/json'
|
|
886
|
+
)
|
|
887
|
+
]
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
def build_resources
|
|
891
|
+
[
|
|
892
|
+
::MCP::Resource.new(
|
|
893
|
+
uri: 'codebase://manifest',
|
|
894
|
+
name: 'manifest',
|
|
895
|
+
description: 'Extraction manifest with version info, unit counts, and git metadata',
|
|
896
|
+
mime_type: 'application/json'
|
|
897
|
+
),
|
|
898
|
+
::MCP::Resource.new(
|
|
899
|
+
uri: 'codebase://graph',
|
|
900
|
+
name: 'dependency-graph',
|
|
901
|
+
description: 'Full dependency graph with nodes, edges, and type index',
|
|
902
|
+
mime_type: 'application/json'
|
|
903
|
+
)
|
|
904
|
+
]
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
def register_resource_handler(server, reader)
|
|
908
|
+
server.resources_read_handler do |params|
|
|
909
|
+
uri = params[:uri]
|
|
910
|
+
case uri
|
|
911
|
+
when 'codebase://manifest'
|
|
912
|
+
[{ uri: uri, mimeType: 'application/json', text: JSON.pretty_generate(reader.manifest) }]
|
|
913
|
+
when 'codebase://graph'
|
|
914
|
+
[{ uri: uri, mimeType: 'application/json', text: JSON.pretty_generate(reader.raw_graph_data) }]
|
|
915
|
+
when %r{\Acodebase://unit/(.+)\z}
|
|
916
|
+
identifier = Regexp.last_match(1)
|
|
917
|
+
unit = reader.find_unit(identifier)
|
|
918
|
+
if unit
|
|
919
|
+
[{ uri: uri, mimeType: 'application/json', text: JSON.pretty_generate(unit) }]
|
|
920
|
+
else
|
|
921
|
+
[{ uri: uri, mimeType: 'text/plain', text: "Unit not found: #{identifier}" }]
|
|
922
|
+
end
|
|
923
|
+
when %r{\Acodebase://type/(.+)\z}
|
|
924
|
+
type = Regexp.last_match(1)
|
|
925
|
+
units = reader.list_units(type: type)
|
|
926
|
+
[{ uri: uri, mimeType: 'application/json', text: JSON.pretty_generate(units) }]
|
|
927
|
+
else
|
|
928
|
+
[{ uri: uri, mimeType: 'text/plain', text: "Unknown resource: #{uri}" }]
|
|
929
|
+
end
|
|
930
|
+
end
|
|
931
|
+
end
|
|
932
|
+
end
|
|
933
|
+
end
|
|
934
|
+
end
|
|
935
|
+
end
|