rails-ai-context 5.32.2 → 5.34.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 +4 -4
- data/CHANGELOG.md +1491 -0
- data/CONTEXT.md +27 -3
- data/README.md +16 -12
- data/Rakefile +6 -0
- data/app/controllers/rails_ai_context/mcp_controller.rb +112 -84
- data/config/routes.rb +2 -3
- data/docs/ARCHITECTURE.md +7 -5
- data/docs/CLI.md +79 -6
- data/docs/COMPATIBILITY.md +75 -1
- data/docs/CONFIGURATION.md +30 -11
- data/docs/CUSTOM_TOOLS.md +2 -1
- data/docs/FAQ.md +16 -6
- data/docs/GUIDE.md +117 -71
- data/docs/INTROSPECTORS.md +10 -8
- data/docs/QUICKSTART.md +3 -3
- data/docs/RECIPES.md +1 -1
- data/docs/SECURITY.md +109 -25
- data/docs/SETUP.md +30 -6
- data/docs/STANDALONE.md +10 -3
- data/docs/TOOLS.md +152 -34
- data/docs/TROUBLESHOOTING.md +90 -12
- data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
- data/docs/adr/0005-workspace-mcp-entries.md +39 -0
- data/exe/rails-ai-context +733 -104
- data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
- data/lib/rails_ai_context/app_kind.rb +18 -5
- data/lib/rails_ai_context/ast_cache.rb +7 -0
- data/lib/rails_ai_context/base_mixins.rb +8 -8
- data/lib/rails_ai_context/boot_manager.rb +22 -3
- data/lib/rails_ai_context/change_watch.rb +14 -11
- data/lib/rails_ai_context/cli/app_root.rb +401 -0
- data/lib/rails_ai_context/cli/entry_boot.rb +58 -22
- data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
- data/lib/rails_ai_context/code_reloader.rb +115 -1
- data/lib/rails_ai_context/concern_membership.rb +3 -1
- data/lib/rails_ai_context/concern_paths.rb +8 -3
- data/lib/rails_ai_context/configuration.rb +41 -9
- data/lib/rails_ai_context/context_file_report.rb +19 -2
- data/lib/rails_ai_context/database_file.rb +26 -0
- data/lib/rails_ai_context/doctor.rb +1122 -184
- data/lib/rails_ai_context/engine.rb +18 -1
- data/lib/rails_ai_context/erb_source.rb +19 -0
- data/lib/rails_ai_context/executable.rb +21 -0
- data/lib/rails_ai_context/facts_formatter.rb +5 -2
- data/lib/rails_ai_context/file_walk.rb +61 -10
- data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
- data/lib/rails_ai_context/fingerprinter.rb +40 -12
- data/lib/rails_ai_context/gem_lock.rb +186 -27
- data/lib/rails_ai_context/git_ignore.rb +22 -0
- data/lib/rails_ai_context/install/ai_tool.rb +31 -5
- data/lib/rails_ai_context/install/cleanup.rb +21 -0
- data/lib/rails_ai_context/install/program.rb +209 -35
- data/lib/rails_ai_context/install/selection_record.rb +258 -97
- data/lib/rails_ai_context/install/surface.rb +6 -1
- data/lib/rails_ai_context/install/validation_hook.rb +282 -0
- data/lib/rails_ai_context/install/workspace.rb +166 -0
- data/lib/rails_ai_context/install_mode.rb +71 -13
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
- data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
- data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
- data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
- data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +15 -10
- data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
- data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +387 -60
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +15 -3
- data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
- data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +23 -10
- data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
- data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
- data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +78 -4
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
- data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
- data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +13 -4
- data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +29 -8
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
- data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
- data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
- data/lib/rails_ai_context/introspectors/migration_replay.rb +15 -14
- data/lib/rails_ai_context/introspectors/model_introspector.rb +231 -23
- data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
- data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +66 -16
- data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
- data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_scan.rb +39 -9
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
- data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +131 -12
- data/lib/rails_ai_context/introspectors/view_introspector.rb +4 -4
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
- data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
- data/lib/rails_ai_context/live_reload.rb +20 -20
- data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
- data/lib/rails_ai_context/mcp_edge.rb +182 -32
- data/lib/rails_ai_context/middleware.rb +5 -1
- data/lib/rails_ai_context/migration_status.rb +88 -25
- data/lib/rails_ai_context/output_guard.rb +48 -2
- data/lib/rails_ai_context/package_json.rb +1 -1
- data/lib/rails_ai_context/path_resolver.rb +46 -13
- data/lib/rails_ai_context/payload.rb +92 -8
- data/lib/rails_ai_context/pending_migrations.rb +5 -1
- data/lib/rails_ai_context/portable_path.rb +3 -2
- data/lib/rails_ai_context/presets.rb +15 -11
- data/lib/rails_ai_context/redaction.rb +262 -51
- data/lib/rails_ai_context/resources.rb +55 -20
- data/lib/rails_ai_context/route_coverage.rb +8 -0
- data/lib/rails_ai_context/safe_file.rb +14 -1
- data/lib/rails_ai_context/safe_path.rb +101 -4
- data/lib/rails_ai_context/schema_adapter.rb +8 -0
- data/lib/rails_ai_context/schema_version.rb +9 -0
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +88 -33
- data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
- data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +19 -16
- data/lib/rails_ai_context/serializers/guide_examples.rb +142 -0
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +146 -30
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +38 -15
- data/lib/rails_ai_context/serializers/section_facts.rb +44 -1
- data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
- data/lib/rails_ai_context/server.rb +304 -71
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -372
- data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
- data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
- data/lib/rails_ai_context/tools/base_tool.rb +367 -46
- data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
- data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
- data/lib/rails_ai_context/tools/diagnose.rb +218 -11
- data/lib/rails_ai_context/tools/generate_test.rb +339 -39
- data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
- data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
- data/lib/rails_ai_context/tools/get_concern.rb +91 -27
- data/lib/rails_ai_context/tools/get_config.rb +13 -4
- data/lib/rails_ai_context/tools/get_context.rb +71 -14
- data/lib/rails_ai_context/tools/get_controllers.rb +179 -44
- data/lib/rails_ai_context/tools/get_conventions.rb +28 -6
- data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
- data/lib/rails_ai_context/tools/get_env.rb +18 -9
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
- data/lib/rails_ai_context/tools/get_gems.rb +1 -1
- data/lib/rails_ai_context/tools/get_helper_methods.rb +10 -6
- data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
- data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -2
- data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
- data/lib/rails_ai_context/tools/get_model_details.rb +30 -10
- data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
- data/lib/rails_ai_context/tools/get_routes.rb +29 -8
- data/lib/rails_ai_context/tools/get_schema.rb +52 -19
- data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
- data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
- data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
- data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
- data/lib/rails_ai_context/tools/get_view.rb +14 -14
- data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
- data/lib/rails_ai_context/tools/onboard.rb +52 -8
- data/lib/rails_ai_context/tools/performance_check.rb +5 -3
- data/lib/rails_ai_context/tools/query.rb +914 -86
- data/lib/rails_ai_context/tools/read_logs.rb +69 -15
- data/lib/rails_ai_context/tools/review_changes.rb +215 -53
- data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
- data/lib/rails_ai_context/tools/safe_call.rb +35 -14
- data/lib/rails_ai_context/tools/search_code.rb +243 -67
- data/lib/rails_ai_context/tools/search_docs.rb +4 -4
- data/lib/rails_ai_context/tools/security_scan.rb +143 -33
- data/lib/rails_ai_context/tools/session_context.rb +39 -5
- data/lib/rails_ai_context/tools/validate.rb +259 -40
- data/lib/rails_ai_context/tools/validate_semantics.rb +130 -56
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +67 -40
- data/lib/rails_ai_context/view_file.rb +41 -3
- data/lib/rails_ai_context/watcher.rb +51 -13
- data/lib/rails_ai_context.rb +21 -0
- data/server.json +1 -1
- metadata +14 -1
|
@@ -4,7 +4,7 @@ require "did_you_mean"
|
|
|
4
4
|
|
|
5
5
|
require "mcp"
|
|
6
6
|
require "active_support"
|
|
7
|
-
require "
|
|
7
|
+
require "bigdecimal"
|
|
8
8
|
|
|
9
9
|
module RailsAiContext
|
|
10
10
|
module Tools
|
|
@@ -70,6 +70,16 @@ module RailsAiContext
|
|
|
70
70
|
# for thread safety in multi-threaded servers (e.g., Puma).
|
|
71
71
|
SHARED_CACHE = { mutex: Mutex.new }
|
|
72
72
|
|
|
73
|
+
# What a server last saw of its app's files (a Fingerprinter::Snapshot),
|
|
74
|
+
# when the check now running began, and when the last finished one
|
|
75
|
+
# did. The snapshot is nil until a server starts, so a process that is
|
|
76
|
+
# no server (the CLI, rake) never walks the tree. `stale_code` is what a
|
|
77
|
+
# server that cannot reload found changed since it loaded it
|
|
78
|
+
# (CodeReloader.changed_code), and `compile_cached` what a server that
|
|
79
|
+
# can found rewritten where Bootsnap's compile cache cannot see it
|
|
80
|
+
# (CodeReloader.compile_cached).
|
|
81
|
+
FILE_CHECK = { mutex: Mutex.new, done: ConditionVariable.new, snapshot: nil, running: nil, finished: nil, stale_code: nil, compile_cached: nil }
|
|
82
|
+
|
|
73
83
|
# Session-level context tracking. Lets AI avoid redundant queries
|
|
74
84
|
# by recording what tools have been called with what params.
|
|
75
85
|
# In-memory only - resets on server restart.
|
|
@@ -80,8 +90,11 @@ module RailsAiContext
|
|
|
80
90
|
# Mcp-Session-Id gets its own bucket and one client's history stays out
|
|
81
91
|
# of another's.
|
|
82
92
|
# A plain Hash, not one with a default block: a default block writes on
|
|
83
|
-
# lookup, so merely reading a session's history created it.
|
|
84
|
-
|
|
93
|
+
# lookup, so merely reading a session's history created it. `dropped`
|
|
94
|
+
# counts, per session, the queries its record no longer keeps. `asked`
|
|
95
|
+
# numbers queries in the order first asked, which a timestamp cannot
|
|
96
|
+
# tell apart within one second.
|
|
97
|
+
SESSION_CONTEXT = { mutex: Mutex.new, queries: {}, dropped: {}, asked: 0 }
|
|
85
98
|
|
|
86
99
|
DEFAULT_SESSION = :default
|
|
87
100
|
|
|
@@ -91,6 +104,10 @@ module RailsAiContext
|
|
|
91
104
|
# MAX_SESSIONS is the one least likely to ask about its own history.
|
|
92
105
|
MAX_SESSIONS = 100
|
|
93
106
|
MAX_SESSION_ID_LENGTH = 200
|
|
107
|
+
# What one session's record holds is capped the same way: its latest
|
|
108
|
+
# queries, each param kept as an answer echoes it. A client sending a
|
|
109
|
+
# 100 KB argument on every call otherwise grew the record without end.
|
|
110
|
+
MAX_SESSION_QUERIES = 200
|
|
94
111
|
|
|
95
112
|
# One row of the generated tool guide, declared beside the tool's own
|
|
96
113
|
# description so adding a tool touches one file. `order` fixes where the
|
|
@@ -119,6 +136,15 @@ module RailsAiContext
|
|
|
119
136
|
RailsAiContext.configuration
|
|
120
137
|
end
|
|
121
138
|
|
|
139
|
+
# Every tool's arguments are checked through ArgumentSchema, so an
|
|
140
|
+
# app whose locale files do not load still gets argument errors.
|
|
141
|
+
def input_schema(*args)
|
|
142
|
+
return super if args.empty?
|
|
143
|
+
|
|
144
|
+
value = args.first
|
|
145
|
+
super(value.is_a?(Hash) ? ArgumentSchema.new(value) : value)
|
|
146
|
+
end
|
|
147
|
+
|
|
122
148
|
# A bare `Rails.env` raises NameError under --no-boot or early boot death.
|
|
123
149
|
def rails_env_name
|
|
124
150
|
RailsAiContext.environment_name
|
|
@@ -145,12 +171,9 @@ module RailsAiContext
|
|
|
145
171
|
ttl = RailsAiContext.configuration.cache_ttl
|
|
146
172
|
|
|
147
173
|
# Fast path: within TTL window, trust the cache and skip the
|
|
148
|
-
# fingerprint walk entirely.
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
# dev mode, ~0.5ms in production. Since LiveReload fires
|
|
152
|
-
# reset_all_caches! on actual file-change events, stale-cache
|
|
153
|
-
# risk during a short TTL window is already covered.
|
|
174
|
+
# fingerprint walk entirely. A server has already dropped it if a
|
|
175
|
+
# file changed: each call asks the files at its start
|
|
176
|
+
# (refresh_if_files_changed!).
|
|
154
177
|
if SHARED_CACHE[:context] && (now - SHARED_CACHE[:timestamp]) < ttl
|
|
155
178
|
return SHARED_CACHE[:context].deep_dup
|
|
156
179
|
end
|
|
@@ -182,7 +205,8 @@ module RailsAiContext
|
|
|
182
205
|
end
|
|
183
206
|
end
|
|
184
207
|
|
|
185
|
-
#
|
|
208
|
+
# Every cache that describes the app's files, dropped once a call's
|
|
209
|
+
# check finds them moved.
|
|
186
210
|
def reset_all_caches!
|
|
187
211
|
reset_cache!
|
|
188
212
|
session_reset!
|
|
@@ -190,6 +214,95 @@ module RailsAiContext
|
|
|
190
214
|
PathResolver.clear_code_roots
|
|
191
215
|
end
|
|
192
216
|
|
|
217
|
+
# Called by every server as it starts - stdio, HTTP, the endpoints
|
|
218
|
+
# inside the app - live reload or not. From here on each call checks
|
|
219
|
+
# the files against what they are now.
|
|
220
|
+
#
|
|
221
|
+
# A server that cannot read its files still serves; it only goes
|
|
222
|
+
# without the check.
|
|
223
|
+
def check_files_per_call!(app)
|
|
224
|
+
snapshot = Fingerprinter::Snapshot.new(app.root)
|
|
225
|
+
snapshot.changed?
|
|
226
|
+
FILE_CHECK[:mutex].synchronize { FILE_CHECK.merge!(snapshot: snapshot, running: nil, finished: nil) }
|
|
227
|
+
rescue StandardError => e
|
|
228
|
+
RailsAiContext.debug_fail(e, nil, label: "check_files_per_call!")
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Before a call reads anything, it sees every edit made before it
|
|
232
|
+
# began: when a file moved, the app's code is reloaded on the
|
|
233
|
+
# calling thread and the caches dropped. Live reload only tells
|
|
234
|
+
# clients: `listen` delivers a change after its debounce, a second
|
|
235
|
+
# and a half on, and an agent that edits a model and asks about it
|
|
236
|
+
# at once was answered from before the edit.
|
|
237
|
+
#
|
|
238
|
+
# The check is a stat of each watched directory and file
|
|
239
|
+
# (Fingerprinter::Snapshot): about a millisecond on a small app, about
|
|
240
|
+
# 30 at 10,000 files. Only a check that began after a call did can
|
|
241
|
+
# stand in for that call's own, so two calls one after the other each
|
|
242
|
+
# check, and concurrent calls wait for one check together. A tool
|
|
243
|
+
# another tool calls is part of the first call and asks nothing.
|
|
244
|
+
#
|
|
245
|
+
# Outside SHARED_CACHE's mutex, unlike the TTL walk: a code reload
|
|
246
|
+
# waits for running calls to finish, and they may be waiting on it.
|
|
247
|
+
#
|
|
248
|
+
# An app that cannot reload keeps what reflection read at boot, so
|
|
249
|
+
# instead the call notes which of its files changed since they were
|
|
250
|
+
# loaded, for every answer to name (stale_code_note). One that can
|
|
251
|
+
# notes the files whose reload Bootsnap answers with their code from
|
|
252
|
+
# before the last write.
|
|
253
|
+
def refresh_if_files_changed!
|
|
254
|
+
return if RunCache.active?
|
|
255
|
+
|
|
256
|
+
snapshot = await_check(Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
257
|
+
return unless snapshot
|
|
258
|
+
|
|
259
|
+
begin
|
|
260
|
+
changed = snapshot.changed?
|
|
261
|
+
react_to_change(snapshot) if changed
|
|
262
|
+
done = true
|
|
263
|
+
ensure
|
|
264
|
+
FILE_CHECK[:mutex].synchronize do
|
|
265
|
+
FILE_CHECK[:finished] = FILE_CHECK[:running] if done
|
|
266
|
+
FILE_CHECK[:running] = nil
|
|
267
|
+
FILE_CHECK[:done].broadcast
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
rescue StandardError => e
|
|
271
|
+
RailsAiContext.debug_fail(e, nil, label: "refresh_if_files_changed!")
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Nil once a check that began after the call arrived has finished,
|
|
275
|
+
# since it saw every edit made before; otherwise the snapshot, for
|
|
276
|
+
# this call to check with. A call that finds a check running waits
|
|
277
|
+
# for it, and then for the next if that one began too early.
|
|
278
|
+
private def await_check(arrived)
|
|
279
|
+
FILE_CHECK[:mutex].synchronize do
|
|
280
|
+
loop do
|
|
281
|
+
return nil unless FILE_CHECK[:snapshot]
|
|
282
|
+
return nil if FILE_CHECK[:finished] && FILE_CHECK[:finished] >= arrived
|
|
283
|
+
|
|
284
|
+
unless FILE_CHECK[:running]
|
|
285
|
+
FILE_CHECK[:running] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
286
|
+
return FILE_CHECK[:snapshot]
|
|
287
|
+
end
|
|
288
|
+
FILE_CHECK[:done].wait(FILE_CHECK[:mutex])
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
private def react_to_change(snapshot)
|
|
294
|
+
if CodeReloader.reloadable?
|
|
295
|
+
# A request the app serves already ran Rails' own reloader.
|
|
296
|
+
CodeReloader.reload! unless CodeReloader.inside_app_executor?
|
|
297
|
+
cached = CodeReloader.compile_cached(snapshot.same_stat_rewrites)
|
|
298
|
+
FILE_CHECK[:mutex].synchronize { FILE_CHECK[:compile_cached] = cached }
|
|
299
|
+
else
|
|
300
|
+
changed = CodeReloader.changed_code
|
|
301
|
+
FILE_CHECK[:mutex].synchronize { FILE_CHECK[:stale_code] = changed }
|
|
302
|
+
end
|
|
303
|
+
reset_all_caches!
|
|
304
|
+
end
|
|
305
|
+
|
|
193
306
|
# ── Session context helpers ──────────────────────────────────────
|
|
194
307
|
|
|
195
308
|
# Run a block against one conversation's session record. The HTTP
|
|
@@ -223,27 +336,59 @@ module RailsAiContext
|
|
|
223
336
|
end
|
|
224
337
|
|
|
225
338
|
def session_record(tool_name, params, summary = nil)
|
|
339
|
+
params = recorded_params(params)
|
|
226
340
|
SESSION_CONTEXT[:mutex].synchronize do
|
|
227
341
|
bucket = touch_session
|
|
228
342
|
evict_oldest_sessions
|
|
229
343
|
key = session_key(tool_name, params)
|
|
230
|
-
|
|
344
|
+
# Taken out and put back, so the bucket runs least recently used
|
|
345
|
+
# first and the cap below drops the query asked longest ago.
|
|
346
|
+
existing = bucket.delete(key)
|
|
231
347
|
if existing
|
|
232
348
|
existing[:call_count] = (existing[:call_count] || 1) + 1
|
|
233
349
|
existing[:last_timestamp] = Time.now.iso8601
|
|
234
350
|
existing[:summary] = summary if summary
|
|
351
|
+
bucket[key] = existing
|
|
235
352
|
else
|
|
236
353
|
bucket[key] = {
|
|
237
354
|
tool: tool_name.to_s,
|
|
238
355
|
params: params,
|
|
239
356
|
call_count: 1,
|
|
357
|
+
asked: SESSION_CONTEXT[:asked] += 1,
|
|
240
358
|
timestamp: Time.now.iso8601,
|
|
241
359
|
summary: summary
|
|
242
360
|
}
|
|
361
|
+
drop_oldest_queries(bucket)
|
|
243
362
|
end
|
|
244
363
|
end
|
|
245
364
|
end
|
|
246
365
|
|
|
366
|
+
# How many queries the calling session's record has dropped to stay
|
|
367
|
+
# within MAX_SESSION_QUERIES.
|
|
368
|
+
def session_dropped
|
|
369
|
+
SESSION_CONTEXT[:mutex].synchronize { SESSION_CONTEXT[:dropped].fetch(current_session, 0) }
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Each param as an answer would echo it, so the record keeps no more
|
|
373
|
+
# of a 100 KB argument than a reply does.
|
|
374
|
+
def recorded_params(params)
|
|
375
|
+
return echo_input(params) if params.is_a?(String)
|
|
376
|
+
return params unless params.is_a?(Hash)
|
|
377
|
+
|
|
378
|
+
params.transform_values { |value| value.to_s.length > ECHO_LENGTH ? echo_input(value) : value }
|
|
379
|
+
end
|
|
380
|
+
private :recorded_params
|
|
381
|
+
|
|
382
|
+
# Called with the mutex held.
|
|
383
|
+
def drop_oldest_queries(bucket)
|
|
384
|
+
dropped = SESSION_CONTEXT[:dropped]
|
|
385
|
+
while bucket.size > MAX_SESSION_QUERIES
|
|
386
|
+
bucket.shift
|
|
387
|
+
dropped[current_session] = dropped.fetch(current_session, 0) + 1
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
private :drop_oldest_queries
|
|
391
|
+
|
|
247
392
|
# Deep copies: the entries stay live inside the record and keep being
|
|
248
393
|
# mutated by later calls, so handing the originals out would let a
|
|
249
394
|
# caller's snapshot change under it.
|
|
@@ -253,9 +398,22 @@ module RailsAiContext
|
|
|
253
398
|
end
|
|
254
399
|
end
|
|
255
400
|
|
|
401
|
+
# Every conversation's record: for a change that makes every earlier
|
|
402
|
+
# answer stale (live reload), and for a test that starts clean.
|
|
256
403
|
def session_reset!
|
|
257
404
|
SESSION_CONTEXT[:mutex].synchronize do
|
|
258
405
|
SESSION_CONTEXT[:queries].clear
|
|
406
|
+
SESSION_CONTEXT[:dropped].clear
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# The calling conversation's record alone, which is what a client's
|
|
411
|
+
# `action: "reset"` asks for. One HTTP process serves every client,
|
|
412
|
+
# and clearing them all let one client empty the others' records.
|
|
413
|
+
def current_session_reset!
|
|
414
|
+
SESSION_CONTEXT[:mutex].synchronize do
|
|
415
|
+
SESSION_CONTEXT[:queries].delete(current_session)
|
|
416
|
+
SESSION_CONTEXT[:dropped].delete(current_session)
|
|
259
417
|
end
|
|
260
418
|
end
|
|
261
419
|
|
|
@@ -274,7 +432,7 @@ module RailsAiContext
|
|
|
274
432
|
# Called with the mutex held.
|
|
275
433
|
def evict_oldest_sessions
|
|
276
434
|
queries = SESSION_CONTEXT[:queries]
|
|
277
|
-
queries.shift while queries.size > MAX_SESSIONS
|
|
435
|
+
SESSION_CONTEXT[:dropped].delete(queries.shift.first) while queries.size > MAX_SESSIONS
|
|
278
436
|
end
|
|
279
437
|
private :evict_oldest_sessions
|
|
280
438
|
|
|
@@ -307,7 +465,7 @@ module RailsAiContext
|
|
|
307
465
|
def not_found_response(type, name, available, recovery_tool: nil, note: nil)
|
|
308
466
|
# Don't suggest the exact same string the user typed - that's useless
|
|
309
467
|
suggestions = find_closest_matches(name, available) - [ name ]
|
|
310
|
-
lines = [ "#{type} '#{name}' not found." ]
|
|
468
|
+
lines = [ "#{type} '#{echo_input(name)}' not found." ]
|
|
311
469
|
if suggestions.size == 1
|
|
312
470
|
lines << "Did you mean '#{suggestions.first}'?"
|
|
313
471
|
elsif suggestions.any?
|
|
@@ -372,8 +530,8 @@ module RailsAiContext
|
|
|
372
530
|
def response_text(response)
|
|
373
531
|
first = response.content.first
|
|
374
532
|
text = first.is_a?(Hash) ? first[:text].to_s : ""
|
|
375
|
-
# The composing tool's own response carries the
|
|
376
|
-
|
|
533
|
+
# The composing tool's own response carries the banners, once.
|
|
534
|
+
[ static_tier_banner, stale_code_banner ].compact.reduce(text) { |stripped, banner| stripped.gsub(banner, "") }
|
|
377
535
|
end
|
|
378
536
|
|
|
379
537
|
# One-line banner listing introspectors that failed during context
|
|
@@ -472,6 +630,35 @@ module RailsAiContext
|
|
|
472
630
|
"\n\n---\n_#{note}_"
|
|
473
631
|
end
|
|
474
632
|
|
|
633
|
+
# The same, once app code changed under a server that cannot reload:
|
|
634
|
+
# what reflection read is the code as it booted, whatever the file
|
|
635
|
+
# says now. Validations and callbacks are read off the source, so
|
|
636
|
+
# they follow the edit; associations and enums do not. A server that
|
|
637
|
+
# reloads says the same of a file whose reload Bootsnap answered with
|
|
638
|
+
# the code from before its last save (CodeReloader.compile_cached).
|
|
639
|
+
def stale_code_banner
|
|
640
|
+
note = stale_code_note
|
|
641
|
+
note && "\n\n---\n_#{note}_"
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
def stale_code_note
|
|
645
|
+
if (files = FILE_CHECK[:stale_code])&.any?
|
|
646
|
+
"App code changed since this server booted (#{named_files(files)}); RAILS_ENV=#{RailsAiContext.environment_name} " \
|
|
647
|
+
"does not reload code, so what reflection reads, such as associations and enums, is as of boot. " \
|
|
648
|
+
"Restart the server to see the #{files.size == 1 ? "edit" : "edits"}."
|
|
649
|
+
elsif (files = FILE_CHECK[:compile_cached])&.any?
|
|
650
|
+
"App code was saved twice within one second at the same size (#{named_files(files)}); Bootsnap's " \
|
|
651
|
+
"compile cache knows a file by its size and the second it was saved, so what reflection reads, such as " \
|
|
652
|
+
"associations and enums, may be from the earlier save. Save the #{files.size == 1 ? "file" : "files"} " \
|
|
653
|
+
"again a second later, or start the server with DISABLE_BOOTSNAP_COMPILE_CACHE=1."
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
def named_files(files)
|
|
658
|
+
files.size > 3 ? "#{files.first(3).join(", ")} and #{files.size - 3} more" : files.join(", ")
|
|
659
|
+
end
|
|
660
|
+
private :named_files
|
|
661
|
+
|
|
475
662
|
# The banner without its markdown wrapper, so a JSON body can carry
|
|
476
663
|
# the same sentence under a key instead of a footer that would stop it
|
|
477
664
|
# parsing.
|
|
@@ -486,7 +673,7 @@ module RailsAiContext
|
|
|
486
673
|
else reason ? "App boot failed (#{reason})" : "Static mode"
|
|
487
674
|
end
|
|
488
675
|
"[STATIC] #{headline}. Serving static analysis; runtime-only data is marked " \
|
|
489
|
-
"[UNAVAILABLE]. Run `
|
|
676
|
+
"[UNAVAILABLE]. Run `#{RailsAiContext.doctor_command}` for details."
|
|
490
677
|
end
|
|
491
678
|
|
|
492
679
|
# Tools that only make sense against a booted app must refuse in the
|
|
@@ -500,7 +687,7 @@ module RailsAiContext
|
|
|
500
687
|
remedy = case RailsAiContext.static_kind
|
|
501
688
|
when :requested then "Rerun without `--no-boot`."
|
|
502
689
|
when :source_only then "This tree has no `config/environment.rb`; add one (or run from the app root) for runtime data."
|
|
503
|
-
else "Fix the boot failure (see `
|
|
690
|
+
else "Fix the boot failure (see `#{RailsAiContext.doctor_command}`)."
|
|
504
691
|
end
|
|
505
692
|
text_response(
|
|
506
693
|
"[UNAVAILABLE: static tier] #{capability} requires a booted Rails app" \
|
|
@@ -520,6 +707,21 @@ module RailsAiContext
|
|
|
520
707
|
find_closest_matches(input, available).first
|
|
521
708
|
end
|
|
522
709
|
|
|
710
|
+
# Longer than any model, controller, table or file name an app has.
|
|
711
|
+
MAX_NAME_LENGTH = 256
|
|
712
|
+
ECHO_LENGTH = 80
|
|
713
|
+
|
|
714
|
+
# The caller's input as an answer repeats it: a 100 KB argument came
|
|
715
|
+
# back whole in a "not found", a 100,321-character reply.
|
|
716
|
+
def echo_input(value)
|
|
717
|
+
text = value.to_s
|
|
718
|
+
text.length > ECHO_LENGTH ? "#{text[0, ECHO_LENGTH]}... (#{text.length} characters)" : text
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
def name_too_long?(value)
|
|
722
|
+
value.to_s.length > MAX_NAME_LENGTH
|
|
723
|
+
end
|
|
724
|
+
|
|
523
725
|
# Every name that matches as well as the best one does. A bare
|
|
524
726
|
# `ReportsController` names three real controllers under different
|
|
525
727
|
# namespaces, and answering with one of them arbitrarily hides the
|
|
@@ -530,6 +732,9 @@ module RailsAiContext
|
|
|
530
732
|
# so it would otherwise surface an arbitrary "Did you mean" suggestion
|
|
531
733
|
# for input that isn't a typo at all - just missing.
|
|
532
734
|
return [] if input.to_s.strip.empty?
|
|
735
|
+
# No name is this long, and spell-checking a 100 KB string against
|
|
736
|
+
# every model took seconds to find nothing.
|
|
737
|
+
return [] if input.to_s.length > MAX_NAME_LENGTH
|
|
533
738
|
|
|
534
739
|
exact = exact_matches(input, available)
|
|
535
740
|
return exact if exact.any?
|
|
@@ -746,6 +951,9 @@ module RailsAiContext
|
|
|
746
951
|
return "skip_callback :#{event}, :#{kind}, #{callback_target(callback[:method])}#{callback_options_tail(callback[:options])}"
|
|
747
952
|
end
|
|
748
953
|
|
|
954
|
+
# A broadcast macro is its own declaration: the call is the line the file holds.
|
|
955
|
+
return callback[:source].to_s.strip.gsub(/\s+/, " ") if callback[:runs]
|
|
956
|
+
|
|
749
957
|
name = callback[:name] || callback[:type]
|
|
750
958
|
method = callback[:method].to_s
|
|
751
959
|
target = inline_block_callback?(method) ? "do" : callback_target(method)
|
|
@@ -813,10 +1021,12 @@ module RailsAiContext
|
|
|
813
1021
|
# the text itself when untruncated) so callers can attach a short trailing
|
|
814
1022
|
# note that must survive truncation instead of being cut off with the tail.
|
|
815
1023
|
# In static tier, the tier banner rides along on the same mechanism so
|
|
816
|
-
# every response - caller-suffixed or not - ends with it
|
|
1024
|
+
# every response - caller-suffixed or not - ends with it, and so does
|
|
1025
|
+
# the note that app code changed under a server that cannot reload.
|
|
817
1026
|
def text_response(text, suffix: nil)
|
|
818
|
-
suffix = [ suffix, static_tier_banner ].compact.join
|
|
1027
|
+
suffix = [ suffix, static_tier_banner, stale_code_banner ].compact.join
|
|
819
1028
|
suffix = nil if suffix.empty?
|
|
1029
|
+
text = served_hints(text)
|
|
820
1030
|
|
|
821
1031
|
record_call(text)
|
|
822
1032
|
|
|
@@ -825,29 +1035,133 @@ module RailsAiContext
|
|
|
825
1035
|
truncated = text[0...max]
|
|
826
1036
|
truncated += "\n\n---\n_Response truncated (#{text.length} chars). Use `detail:\"summary\"` for an overview, or filter by a specific item (e.g. `table:\"users\"`)._"
|
|
827
1037
|
truncated += suffix if suffix
|
|
828
|
-
MCP::Tool::Response.new([ { type: "text", text: truncated } ])
|
|
1038
|
+
MCP::Tool::Response.new([ { type: "text", text: cli_form(truncated) } ])
|
|
829
1039
|
else
|
|
830
1040
|
text += suffix if suffix
|
|
831
|
-
MCP::Tool::Response.new([ { type: "text", text: text } ])
|
|
1041
|
+
MCP::Tool::Response.new([ { type: "text", text: cli_form(text) } ])
|
|
1042
|
+
end
|
|
1043
|
+
end
|
|
1044
|
+
|
|
1045
|
+
# A call to one of this gem's tools as an answer writes it:
|
|
1046
|
+
# `rails_get_schema(table:"posts")`. Under tool_mode :cli no MCP server
|
|
1047
|
+
# is set up, so the answer names the command that runs instead, as the
|
|
1048
|
+
# context files do; a lone `detail:"summary"` hint becomes `detail=summary`.
|
|
1049
|
+
MCP_CALL = /\b(rails_\w+)\(([^()]*)\)/
|
|
1050
|
+
PARAM_HINT = /`((?:\w+:(?:"[^"`]*"|[\w.]+)(?:,\s*)?)+)`/
|
|
1051
|
+
|
|
1052
|
+
# A `_Next:` hint points at other tools, one pointer per tool, joined
|
|
1053
|
+
# by ` | ` on one line or onto lines of their own. A tool skip_tools
|
|
1054
|
+
# turned off is no next step, so its pointer goes, and a hint left
|
|
1055
|
+
# with none goes whole. Every answer passes here, so no tool has to
|
|
1056
|
+
# check the served set itself.
|
|
1057
|
+
def served_hints(text)
|
|
1058
|
+
return text unless text.is_a?(String) && text.include?("_Next: ")
|
|
1059
|
+
|
|
1060
|
+
skipped = RailsAiContext::Server.skipped_tools
|
|
1061
|
+
return text if skipped.empty?
|
|
1062
|
+
|
|
1063
|
+
lines = text.split("\n", -1)
|
|
1064
|
+
kept = []
|
|
1065
|
+
index = 0
|
|
1066
|
+
while index < lines.size
|
|
1067
|
+
block = [ lines[index] ]
|
|
1068
|
+
if block.first.start_with?("_Next: ")
|
|
1069
|
+
block << lines[index += 1] while lines[index + 1]&.start_with?(" | ")
|
|
1070
|
+
hint = served_hint(block, skipped)
|
|
1071
|
+
if hint
|
|
1072
|
+
kept.concat(hint)
|
|
1073
|
+
elsif kept.last == "" && lines[index + 1].to_s.empty?
|
|
1074
|
+
kept.pop
|
|
1075
|
+
end
|
|
1076
|
+
else
|
|
1077
|
+
kept << block.first
|
|
1078
|
+
end
|
|
1079
|
+
index += 1
|
|
1080
|
+
end
|
|
1081
|
+
kept.join("\n")
|
|
1082
|
+
rescue StandardError => e
|
|
1083
|
+
RailsAiContext.debug_fail(e, text, label: "served_hints")
|
|
1084
|
+
end
|
|
1085
|
+
|
|
1086
|
+
# The hint's lines with the pointers to skipped tools taken out; nil
|
|
1087
|
+
# when none is left.
|
|
1088
|
+
def served_hint(block, skipped)
|
|
1089
|
+
body = block.join("\n").delete_prefix("_Next: ")
|
|
1090
|
+
closing = body[/\.?_\z/].to_s
|
|
1091
|
+
pointers = body.delete_suffix(closing).split(/\n? \| /)
|
|
1092
|
+
served = pointers.reject { |pointer| skipped.include?(pointer[/\brails_\w+/]) }
|
|
1093
|
+
return block if served.size == pointers.size
|
|
1094
|
+
return nil if served.empty?
|
|
1095
|
+
|
|
1096
|
+
"_Next: #{served.join(block.size > 1 ? "\n | " : " | ")}#{closing}".split("\n")
|
|
1097
|
+
end
|
|
1098
|
+
private :served_hint
|
|
1099
|
+
|
|
1100
|
+
def cli_form(text)
|
|
1101
|
+
return text unless RailsAiContext.configuration.tool_mode == :cli && text.is_a?(String)
|
|
1102
|
+
|
|
1103
|
+
names = BaseTool.registered_tools.map { |tool| tool.tool_name.to_s }
|
|
1104
|
+
converted = text.gsub(MCP_CALL) do
|
|
1105
|
+
whole = Regexp.last_match(0)
|
|
1106
|
+
name, args = Regexp.last_match(1), Regexp.last_match(2)
|
|
1107
|
+
params = names.include?(name) && cli_params(args)
|
|
1108
|
+
next whole unless params
|
|
1109
|
+
|
|
1110
|
+
short = name.sub(/\Arails_get_/, "").sub(/\Arails_/, "")
|
|
1111
|
+
command = RailsAiContext::InstallMode.tool_command(short)
|
|
1112
|
+
[ command, *params ].join(" ")
|
|
1113
|
+
end
|
|
1114
|
+
converted.gsub(PARAM_HINT) { (params = cli_params(Regexp.last_match(1))) ? "`#{params.join(' ')}`" : Regexp.last_match(0) }
|
|
1115
|
+
rescue StandardError => e
|
|
1116
|
+
RailsAiContext.debug_fail(e, text, label: "cli_form")
|
|
1117
|
+
end
|
|
1118
|
+
|
|
1119
|
+
# `model:"User", files:["a.rb","b.rb"]` as `model=User files=a.rb,b.rb`,
|
|
1120
|
+
# quoted for a shell where a value needs it; nil when the text is not
|
|
1121
|
+
# keyword arguments.
|
|
1122
|
+
def cli_params(args)
|
|
1123
|
+
return [] if args.strip.empty?
|
|
1124
|
+
|
|
1125
|
+
call = RailsAiContext::AstCache.parse_string("f(#{args})").value.statements.body.first
|
|
1126
|
+
hash = call.is_a?(Prism::CallNode) ? call.arguments&.arguments&.first : nil
|
|
1127
|
+
return nil unless hash.is_a?(Prism::KeywordHashNode)
|
|
1128
|
+
|
|
1129
|
+
hash.elements.map do |pair|
|
|
1130
|
+
return nil unless pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode)
|
|
1131
|
+
|
|
1132
|
+
value = case (node = pair.value)
|
|
1133
|
+
when Prism::StringNode, Prism::SymbolNode then node.unescaped
|
|
1134
|
+
when Prism::ArrayNode then node.elements.map { |e| e.respond_to?(:unescaped) ? e.unescaped : e.slice }.join(",")
|
|
1135
|
+
else node.slice
|
|
1136
|
+
end
|
|
1137
|
+
"#{pair.key.unescaped}=#{value.match?(%r{\A[\w.,/:@+-]*\z}) && !value.empty? ? value : "\"#{value.gsub(/["\\$`]/) { "\\#{Regexp.last_match(0)}" }}\""}"
|
|
832
1138
|
end
|
|
833
1139
|
end
|
|
834
1140
|
|
|
835
1141
|
# Key the static-tier note rides under in a JSON body. Underscored the
|
|
836
1142
|
# way JsonBudget's own report key is, so a reader tells it from data.
|
|
837
1143
|
STATIC_TIER_KEY = "_static_tier"
|
|
1144
|
+
STALE_CODE_KEY = "_stale_code"
|
|
838
1145
|
|
|
839
|
-
# A JSON body has to parse, so it cannot take the markdown
|
|
1146
|
+
# A JSON body has to parse, so it cannot take the markdown banners and
|
|
840
1147
|
# cannot be sliced at the response cap: JsonBudget drops whole
|
|
841
|
-
# elements instead, and the
|
|
1148
|
+
# elements instead, and the notes ride under reserved keys.
|
|
842
1149
|
def json_response(data)
|
|
843
|
-
|
|
844
|
-
data = data.merge(STATIC_TIER_KEY => note) if note && data.is_a?(Hash)
|
|
845
|
-
|
|
846
|
-
text = JsonBudget.generate(data, RailsAiContext.configuration.max_tool_response_chars)
|
|
1150
|
+
text = JsonBudget.generate(json_notes(data), RailsAiContext.configuration.max_tool_response_chars)
|
|
847
1151
|
record_call(text)
|
|
848
1152
|
MCP::Tool::Response.new([ { type: "text", text: text } ])
|
|
849
1153
|
end
|
|
850
1154
|
|
|
1155
|
+
# The notes a text answer ends with, under their keys: that the answer
|
|
1156
|
+
# is static analysis, and that what reflection read is not the code
|
|
1157
|
+
# the files hold now. A tool's JSON answer and a resource's body both
|
|
1158
|
+
# carry them, so neither reads as current when it is not.
|
|
1159
|
+
def json_notes(data)
|
|
1160
|
+
return data unless data.is_a?(Hash)
|
|
1161
|
+
|
|
1162
|
+
data.merge({ STATIC_TIER_KEY => static_tier_note, STALE_CODE_KEY => stale_code_note }.compact)
|
|
1163
|
+
end
|
|
1164
|
+
|
|
851
1165
|
# Helper: wrap text in an MCP::Tool::Response flagged as an error
|
|
852
1166
|
# (isError: true) so MCP clients and the CLI treat the call as failed
|
|
853
1167
|
# (non-zero exit). Mirrors the SafeCall rescue wrapper. Use for an
|
|
@@ -856,12 +1170,8 @@ module RailsAiContext
|
|
|
856
1170
|
# would not answer. Guidance and "found nothing" stay informational
|
|
857
1171
|
# via text_response and empty_response.
|
|
858
1172
|
def error_response(text)
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
Thread.current[:rails_ai_context_call_params] = nil
|
|
862
|
-
banner = static_tier_banner
|
|
863
|
-
text += banner if banner
|
|
864
|
-
MCP::Tool::Response.new([ { type: "text", text: text } ], error: true)
|
|
1173
|
+
text += [ static_tier_banner, stale_code_banner ].compact.join
|
|
1174
|
+
MCP::Tool::Response.new([ { type: "text", text: cli_form(text) } ], error: true)
|
|
865
1175
|
end
|
|
866
1176
|
|
|
867
1177
|
private
|
|
@@ -882,22 +1192,32 @@ module RailsAiContext
|
|
|
882
1192
|
Array(RailsAiContext::Payload.section(cached_context, :i18n)&.dig(:available_locales)).map(&:to_s)
|
|
883
1193
|
end
|
|
884
1194
|
|
|
885
|
-
|
|
1195
|
+
STORAGE_UNITS = %w[Bytes KB MB GB TB PB EB ZB].freeze
|
|
1196
|
+
|
|
1197
|
+
# number_to_human_size's English output, without I18n: an app's backend can raise after a failed boot.
|
|
886
1198
|
def human_size(bytes)
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
1199
|
+
bytes = bytes.to_i
|
|
1200
|
+
return "#{bytes} #{bytes == 1 ? "Byte" : "Bytes"}" if bytes < 1024
|
|
1201
|
+
|
|
1202
|
+
exponent = [ (Math.log(bytes) / Math.log(1024)).to_i, STORAGE_UNITS.size - 1 ].min
|
|
1203
|
+
value = bytes.to_f / (1024**exponent)
|
|
1204
|
+
places = 3 - (Math.log10(value) + 1).floor
|
|
1205
|
+
rounded = format("%.#{[ places, 0 ].max}f", BigDecimal(value.to_s).round(places, :half_up))
|
|
1206
|
+
"#{rounded.sub(/(\.\d*?)0+\z/, '\1').delete_suffix(".")} #{STORAGE_UNITS[exponent]}"
|
|
890
1207
|
end
|
|
891
1208
|
|
|
892
1209
|
# Every answered call is recorded so session_context(action:"status")
|
|
893
|
-
# can list it
|
|
1210
|
+
# can list it: the call SafeCall says the client made, once, with its
|
|
1211
|
+
# own params. A tool another tool called answers it, not the client.
|
|
1212
|
+
# SessionContext itself is skipped to avoid recursion.
|
|
894
1213
|
def record_call(text)
|
|
1214
|
+
call = Thread.current[:rails_ai_context_call]
|
|
1215
|
+
return unless call && call[:tool].equal?(self) && !call[:recorded]
|
|
895
1216
|
return unless respond_to?(:tool_name) && tool_name != "rails_session_context"
|
|
896
1217
|
|
|
897
1218
|
summary = text.lines.first&.strip&.truncate(80)
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
Thread.current[:rails_ai_context_call_params] = nil
|
|
1219
|
+
session_record(tool_name, call[:params], summary)
|
|
1220
|
+
call[:recorded] = true
|
|
901
1221
|
end
|
|
902
1222
|
|
|
903
1223
|
def session_key(tool_name, params)
|
|
@@ -935,8 +1255,8 @@ module RailsAiContext
|
|
|
935
1255
|
# @return [String, nil] the refusal for a path the caller may not read
|
|
936
1256
|
def unsafe_path_message(path)
|
|
937
1257
|
case RailsAiContext::SafePath.locate(path.to_s, under: rails_app.root.to_s).refusal
|
|
938
|
-
when :traversal, :outside then "Path not allowed: #{path}"
|
|
939
|
-
when :sensitive then "Path not allowed: #{path} (sensitive file)"
|
|
1258
|
+
when :traversal, :outside then "Path not allowed: #{echo_input(path)}"
|
|
1259
|
+
when :sensitive then "Path not allowed: #{echo_input(path)} (sensitive file)"
|
|
940
1260
|
end
|
|
941
1261
|
end
|
|
942
1262
|
|
|
@@ -986,13 +1306,14 @@ module RailsAiContext
|
|
|
986
1306
|
|
|
987
1307
|
# Whether the loaded model class defines the method: true, false, or
|
|
988
1308
|
# nil when there is no loaded class to ask (the static tier, a name
|
|
989
|
-
# that is not a model
|
|
990
|
-
#
|
|
1309
|
+
# that is not a model, a model whose file does not compile, which is
|
|
1310
|
+
# never required to ask it). A private method counts as defined, since
|
|
1311
|
+
# the error for calling one says so rather than "undefined".
|
|
991
1312
|
def live_method_defined?(receiver, method_name)
|
|
992
1313
|
return nil if RailsAiContext.static_tier?
|
|
993
1314
|
return nil unless defined?(ActiveRecord::Base)
|
|
994
1315
|
|
|
995
|
-
klass = receiver
|
|
1316
|
+
klass = Introspectors::EagerLoad.constant(receiver)
|
|
996
1317
|
return nil unless klass.is_a?(Class) && klass < ActiveRecord::Base
|
|
997
1318
|
|
|
998
1319
|
define_attribute_methods(klass)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiContext
|
|
4
|
+
module Tools
|
|
5
|
+
# Brakeman's vendored ruby_parser runs `$DEBUG = true if ENV["DEBUG"]`
|
|
6
|
+
# when it loads, and with $DEBUG on Ruby prints every exception raised
|
|
7
|
+
# anywhere in the process, rescued or not: a `DEBUG=1` run, which this
|
|
8
|
+
# gem's own error text tells users to try, printed some 440 lines of them.
|
|
9
|
+
# Loading and running brakeman with DEBUG out of its sight, and putting
|
|
10
|
+
# $DEBUG back after, leaves that switch the user's.
|
|
11
|
+
module BrakemanGuard
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def quietly
|
|
15
|
+
hidden = ENV.delete("DEBUG")
|
|
16
|
+
saved = $DEBUG
|
|
17
|
+
yield
|
|
18
|
+
ensure
|
|
19
|
+
$DEBUG = saved
|
|
20
|
+
ENV["DEBUG"] = hidden if hidden
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|