fosm-rails-coding-agent 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +77 -0
  3. data/CHANGELOG.md +16 -0
  4. data/LICENSE +127 -0
  5. data/README.md +135 -0
  6. data/bin/fosm-coding-agent +14 -0
  7. data/lib/fosm_rails_coding_agent/acp_agent.rb +132 -0
  8. data/lib/fosm_rails_coding_agent/configuration.rb +27 -0
  9. data/lib/fosm_rails_coding_agent/exceptions_middleware.rb +67 -0
  10. data/lib/fosm_rails_coding_agent/middleware.rb +115 -0
  11. data/lib/fosm_rails_coding_agent/quiet_middleware.rb +18 -0
  12. data/lib/fosm_rails_coding_agent/railtie.rb +51 -0
  13. data/lib/fosm_rails_coding_agent/tools/base.rb +10 -0
  14. data/lib/fosm_rails_coding_agent/tools/execute_sql.rb +44 -0
  15. data/lib/fosm_rails_coding_agent/tools/fosm/available_events.rb +46 -0
  16. data/lib/fosm_rails_coding_agent/tools/fosm/fire_event.rb +46 -0
  17. data/lib/fosm_rails_coding_agent/tools/fosm/inspect_lifecycle.rb +44 -0
  18. data/lib/fosm_rails_coding_agent/tools/fosm/list_lifecycles.rb +45 -0
  19. data/lib/fosm_rails_coding_agent/tools/fosm/model_resolver.rb +25 -0
  20. data/lib/fosm_rails_coding_agent/tools/fosm/transition_history.rb +56 -0
  21. data/lib/fosm_rails_coding_agent/tools/fosm/why_blocked.rb +36 -0
  22. data/lib/fosm_rails_coding_agent/tools/get_docs.rb +58 -0
  23. data/lib/fosm_rails_coding_agent/tools/get_logs.rb +66 -0
  24. data/lib/fosm_rails_coding_agent/tools/get_models.rb +35 -0
  25. data/lib/fosm_rails_coding_agent/tools/get_source_location.rb +69 -0
  26. data/lib/fosm_rails_coding_agent/tools/project_eval.rb +76 -0
  27. data/lib/fosm_rails_coding_agent/transport.rb +84 -0
  28. data/lib/fosm_rails_coding_agent/version.rb +5 -0
  29. data/lib/fosm_rails_coding_agent.rb +26 -0
  30. metadata +139 -0
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "fast_mcp"
5
+ require "rack/request"
6
+ require "json"
7
+ require_relative "transport"
8
+
9
+ module FosmRailsCodingAgent
10
+ # Rack middleware that serves the FosmRailsCodingAgent MCP server.
11
+ # Handles tool registration, localhost enforcement, and MCP routing.
12
+ # FOSM tools are registered conditionally when fosm-rails is detected.
13
+ class Middleware
14
+ ROUTE_PREFIX = "fosm-agent"
15
+ MCP_ROUTE = "mcp"
16
+
17
+ REMOTE_BLOCKED = <<~MSG.freeze
18
+ FosmRailsCodingAgent does not accept remote connections by default.
19
+ Set config.fosm_coding_agent.allow_remote_access = true if you need this.
20
+ MSG
21
+
22
+ def initialize(app, config)
23
+ @allow_remote = config.allow_remote_access
24
+ @project_name = Rails.application.class.module_parent.name
25
+
26
+ @app = FastMcp.rack_middleware(app,
27
+ name: "fosm-rails-coding-agent",
28
+ version: FosmRailsCodingAgent::VERSION,
29
+ path_prefix: "/#{ROUTE_PREFIX}/#{MCP_ROUTE}",
30
+ transport: FosmRailsCodingAgent::Transport,
31
+ logger: config.logger || Logger.new(Rails.root.join("log", "fosm-coding-agent.log")),
32
+ allowed_origins: [],
33
+ localhost_only: false
34
+ ) do |server|
35
+ # Register core tools (always available)
36
+ core_tools = FosmRailsCodingAgent::Tools::Base.descendants.reject do |tool|
37
+ tool.module_parents.include?(FosmRailsCodingAgent::Tools::Fosm)
38
+ end
39
+ server.register_tools(*core_tools)
40
+
41
+ # Register FOSM tools only when fosm-rails is loaded
42
+ if FosmRailsCodingAgent.fosm_available?
43
+ fosm_tools = FosmRailsCodingAgent::Tools::Base.descendants.select do |tool|
44
+ tool.module_parents.include?(FosmRailsCodingAgent::Tools::Fosm)
45
+ end
46
+ server.register_tools(*fosm_tools) if fosm_tools.any?
47
+ end
48
+ end
49
+ end
50
+
51
+ def call(env)
52
+ request = Rack::Request.new(env)
53
+ path = request.path.split("/").reject(&:empty?)
54
+
55
+ if path.first == ROUTE_PREFIX
56
+ return forbidden(REMOTE_BLOCKED) unless local_ip?(request)
57
+
58
+ case [request.request_method, path]
59
+ when ["GET", [ROUTE_PREFIX]]
60
+ return home_page
61
+ when ["GET", [ROUTE_PREFIX, "config"]]
62
+ return config_endpoint
63
+ end
64
+ end
65
+
66
+ status, headers, body = @app.call(env)
67
+ headers.delete("X-Frame-Options")
68
+ [status, headers, body]
69
+ end
70
+
71
+ private
72
+
73
+ def home_page
74
+ [200, { "Content-Type" => "text/html" }, [<<~HTML]]
75
+ <!DOCTYPE html>
76
+ <html>
77
+ <head><meta charset="UTF-8"><title>FosmRailsCodingAgent</title></head>
78
+ <body>
79
+ <h1>FosmRailsCodingAgent</h1>
80
+ <p>FOSM-aware runtime intelligence is active.</p>
81
+ <p>MCP endpoint: <code>/fosm-agent/mcp</code></p>
82
+ <p>FOSM: #{FosmRailsCodingAgent.fosm_available? ? "detected" : "not detected"}</p>
83
+ <p>Project: #{@project_name}</p>
84
+ </body>
85
+ </html>
86
+ HTML
87
+ end
88
+
89
+ def config_endpoint
90
+ [200, { "Content-Type" => "application/json" }, [JSON.generate({
91
+ project_name: @project_name,
92
+ framework: "rails",
93
+ fosm_agent_version: FosmRailsCodingAgent::VERSION,
94
+ fosm_available: FosmRailsCodingAgent.fosm_available?
95
+ })]]
96
+ end
97
+
98
+ def forbidden(message)
99
+ Rails.logger.warn(message)
100
+ [403, { "Content-Type" => "text/plain" }, [message]]
101
+ end
102
+
103
+ def local_ip?(request)
104
+ return true if @allow_remote
105
+
106
+ addr = IPAddr.new(request.ip.to_s)
107
+ addr.loopback? ||
108
+ addr == IPAddr.new("127.0.0.1") ||
109
+ addr == IPAddr.new("::1") ||
110
+ addr == IPAddr.new("::ffff:127.0.0.1")
111
+ rescue IPAddr::InvalidAddressError
112
+ false
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FosmRailsCodingAgent
4
+ # Suppresses FosmRailsCodingAgent request noise from the Rails development log.
5
+ class QuietMiddleware
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ def call(env)
11
+ if env["PATH_INFO"].start_with?("/fosm-agent")
12
+ Rails.logger.silence { @app.call(env) }
13
+ else
14
+ @app.call(env)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "fosm_rails_coding_agent/configuration"
5
+ require "fosm_rails_coding_agent/middleware"
6
+ require "fosm_rails_coding_agent/exceptions_middleware"
7
+ require "fosm_rails_coding_agent/quiet_middleware"
8
+
9
+ # Load all tools
10
+ Dir[File.expand_path("tools/**/*.rb", __dir__)].each { |f| require f }
11
+
12
+ module FosmRailsCodingAgent
13
+ # Railtie that wires FosmRailsCodingAgent into the Rails middleware stack.
14
+ # Refuses to load in production (requires config.enable_reloading).
15
+ # Sets up MCP server middleware and optionally starts the ACP agent.
16
+ class Railtie < Rails::Railtie
17
+ config.fosm_coding_agent = FosmRailsCodingAgent::Configuration.new
18
+
19
+ initializer "fosm_coding_agent.setup" do |app|
20
+ unless app.config.enable_reloading
21
+ raise "fosm-rails-coding-agent is a development tool. It requires config.enable_reloading = true."
22
+ end
23
+
24
+ app.config.middleware.insert_after(
25
+ ActionDispatch::Callbacks,
26
+ FosmRailsCodingAgent::Middleware,
27
+ app.config.fosm_coding_agent
28
+ )
29
+ end
30
+
31
+ initializer "fosm_coding_agent.intercept_exceptions" do |app|
32
+ ActionDispatch::DebugExceptions.register_interceptor do |request, exception|
33
+ request.set_header("fosm_agent.exception", exception)
34
+ end
35
+
36
+ app.middleware.insert_before(ActionDispatch::DebugExceptions, FosmRailsCodingAgent::ExceptionsMiddleware)
37
+ end
38
+
39
+ initializer "fosm_coding_agent.quiet_logging" do |app|
40
+ app.middleware.insert_before(Rails::Rack::Logger, FosmRailsCodingAgent::QuietMiddleware)
41
+ end
42
+
43
+ initializer "fosm_coding_agent.acp_agent" do |app|
44
+ next unless app.config.fosm_coding_agent.acp_enabled
45
+
46
+ # Register the ACP agent binary path so clients can discover it
47
+ agent_binary = File.expand_path("../../bin/fosm-agent-agent", __dir__)
48
+ Rails.logger&.info("[FosmRailsCodingAgent] ACP agent available at: #{agent_binary}")
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FosmRailsCodingAgent
4
+ module Tools
5
+ # Base class for all FosmRailsCodingAgent MCP tools.
6
+ # Inherits FastMcp::Tool DSL: tool_name, description, arguments, call.
7
+ class Base < FastMcp::Tool
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FosmRailsCodingAgent
4
+ module Tools
5
+ # Executes SQL queries against the application database.
6
+ # Results are capped at the configured row limit (default 50).
7
+ class ExecuteSql < Base
8
+ tool_name "execute_sql"
9
+ description <<~DESC
10
+ Executes the given SQL query against the application database.
11
+ Results are limited to #{FosmRailsCodingAgent::Configuration.new.sql_row_limit} rows.
12
+ Use LIMIT/OFFSET for pagination. Select only the columns you need.
13
+
14
+ For PostgreSQL, use $1, $2 for parameter placeholders.
15
+ For MySQL, use ? for parameter placeholders.
16
+ DESC
17
+
18
+ arguments do
19
+ required(:query).filled(:string).description("The SQL query to execute")
20
+ optional(:arguments).value(:array).description("Bind parameters for the query placeholders")
21
+ end
22
+
23
+ def @input_schema.json_schema
24
+ schema = super
25
+ schema[:properties][:arguments][:items] = {}
26
+ schema
27
+ end
28
+
29
+ def call(query:, arguments: [])
30
+ result = ActiveRecord::Base.connection.exec_query(query, "FOSM Agent SQL", arguments)
31
+ limit = FosmRailsCodingAgent.config.sql_row_limit
32
+
33
+ {
34
+ columns: result.columns,
35
+ rows: result.rows.first(limit),
36
+ row_count: result.rows.size,
37
+ truncated: result.rows.size > limit,
38
+ adapter: ActiveRecord::Base.connection.adapter_name,
39
+ database: ActiveRecord::Base.connection.current_database
40
+ }
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "model_resolver"
5
+
6
+ module FosmRailsCodingAgent
7
+ module Tools
8
+ module Fosm
9
+ # Lists events that can be fired from a record's current state,
10
+ # considering both transition validity and guard evaluation.
11
+ class AvailableEvents < FosmRailsCodingAgent::Tools::Base
12
+ include FosmRailsCodingAgent::Tools::Fosm::ModelResolver
13
+ tool_name "fosm_available_events"
14
+ description <<~DESC
15
+ Returns events that can fire right now on a FOSM record.
16
+ Checks current state, transition rules, and guard conditions.
17
+ Always call this before firing an event.
18
+ DESC
19
+
20
+ arguments do
21
+ required(:model).filled(:string).description("Model class name, e.g. 'Invoice'")
22
+ required(:id).filled(:integer).description("Record ID")
23
+ end
24
+
25
+ def call(model:, id:)
26
+ klass = resolve_model(model)
27
+ record = klass.find(id)
28
+ lifecycle = klass.fosm_lifecycle
29
+
30
+ available = record.available_events
31
+ events_detail = available.map do |event_name|
32
+ event_def = lifecycle.find_event(event_name)
33
+ { name: event_name, to_state: event_def.to_state }
34
+ end
35
+
36
+ JSON.generate({
37
+ model: klass.name,
38
+ id: record.id,
39
+ current_state: record.state,
40
+ available_events: events_detail
41
+ })
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "model_resolver"
5
+
6
+ module FosmRailsCodingAgent
7
+ module Tools
8
+ module Fosm
9
+ # Fires a FOSM lifecycle event on a record.
10
+ # Always uses actor: :agent for audit trail attribution.
11
+ class FireEvent < FosmRailsCodingAgent::Tools::Base
12
+ include FosmRailsCodingAgent::Tools::Fosm::ModelResolver
13
+ tool_name "fosm_fire_event"
14
+ description <<~DESC
15
+ Fires a lifecycle event on a FOSM record.
16
+ The event is attributed to actor: :agent in the audit trail.
17
+ Always check available_events first to avoid errors.
18
+ DESC
19
+
20
+ arguments do
21
+ required(:model).filled(:string).description("Model class name, e.g. 'Invoice'")
22
+ required(:id).filled(:integer).description("Record ID")
23
+ required(:event).filled(:string).description("Event name to fire, e.g. 'send_invoice'")
24
+ optional(:metadata).filled(:hash).description("Optional metadata for the transition log")
25
+ end
26
+
27
+ def call(model:, id:, event:, metadata: {})
28
+ klass = resolve_model(model)
29
+ record = klass.find(id)
30
+ record.fire!(event.to_sym, actor: :agent, metadata: metadata)
31
+
32
+ JSON.generate({
33
+ success: true,
34
+ model: klass.name,
35
+ id: record.id,
36
+ event: event,
37
+ new_state: record.state,
38
+ available_events: record.available_events
39
+ })
40
+ rescue ::Fosm::Error => e
41
+ JSON.generate({ success: false, error: e.message })
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "model_resolver"
5
+
6
+ module FosmRailsCodingAgent
7
+ module Tools
8
+ module Fosm
9
+ # Deep introspection of a single FOSM lifecycle: states, events,
10
+ # guards, side effects, access rules, and diagram data.
11
+ class InspectLifecycle < FosmRailsCodingAgent::Tools::Base
12
+ include FosmRailsCodingAgent::Tools::Fosm::ModelResolver
13
+ tool_name "fosm_inspect_lifecycle"
14
+ description <<~DESC
15
+ Deep introspection of one FOSM lifecycle model class.
16
+ Returns states, events with transitions, guards, side effects,
17
+ access control rules, snapshot configuration, and diagram data.
18
+ DESC
19
+
20
+ arguments do
21
+ required(:model).filled(:string).description("The model class name, e.g. 'Invoice' or 'Fosm::Invoice'")
22
+ end
23
+
24
+ def call(model:)
25
+ klass = resolve_model(model)
26
+ lifecycle = klass.fosm_lifecycle
27
+ raise "#{klass.name} has no FOSM lifecycle defined" unless lifecycle
28
+
29
+ result = {
30
+ model: klass.name,
31
+ states: inspect_states(lifecycle),
32
+ events: inspect_events(lifecycle),
33
+ diagram: lifecycle.to_diagram_data
34
+ }
35
+
36
+ result[:access] = inspect_access(lifecycle) if lifecycle.access_defined?
37
+ result[:snapshots] = inspect_snapshots(lifecycle) if lifecycle.snapshot_configured?
38
+
39
+ JSON.pretty_generate(result)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FosmRailsCodingAgent
4
+ module Tools
5
+ module Fosm
6
+ # Lists all FOSM lifecycle model classes registered in the application,
7
+ # with a summary of their states, events, and guards.
8
+ class ListLifecycles < FosmRailsCodingAgent::Tools::Base
9
+ tool_name "fosm_list_lifecycles"
10
+ description <<~DESC
11
+ Lists all FOSM lifecycle model classes in the application.
12
+ Returns each model with its states, events, and guard names.
13
+ Use this to discover what state machines exist in the project.
14
+ DESC
15
+
16
+ def call
17
+ models = ::Fosm::Registry.model_classes
18
+
19
+ return "No FOSM lifecycles found" if models.empty?
20
+
21
+ models.map do |klass|
22
+ lifecycle = klass.fosm_lifecycle
23
+ next unless lifecycle
24
+
25
+ states = lifecycle.states.map do |s|
26
+ name = s.name.to_s
27
+ name += " (initial)" if s.initial?
28
+ name += " (terminal)" if s.terminal?
29
+ name
30
+ end
31
+
32
+ events = lifecycle.events.map do |e|
33
+ guards = e.guards.map(&:name).join(", ")
34
+ desc = "#{e.name}: #{e.from_states.join(", ")} → #{e.to_state}"
35
+ desc += " [guards: #{guards}]" if guards.present?
36
+ desc
37
+ end
38
+
39
+ "## #{klass.name}\nStates: #{states.join(", ")}\nEvents:\n#{events.map { |e| " - #{e}" }.join("\n")}"
40
+ end.compact.join("\n\n")
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FosmRailsCodingAgent
4
+ module Tools
5
+ module Fosm
6
+ # Shared model resolution logic for FOSM tools.
7
+ # Tries direct constant lookup, Fosm:: prefix, and Registry search.
8
+ module ModelResolver
9
+ private
10
+
11
+ def resolve_model(name)
12
+ Object.const_get(name)
13
+ rescue NameError
14
+ begin
15
+ Object.const_get("Fosm::#{name}")
16
+ rescue NameError
17
+ found = ::Fosm::Registry.model_classes.find { |k| k.name.demodulize == name }
18
+ raise NameError, "FOSM model '#{name}' not found. Use fosm_list_lifecycles to discover available models." unless found
19
+ found
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "model_resolver"
5
+
6
+ module FosmRailsCodingAgent
7
+ module Tools
8
+ module Fosm
9
+ # Returns the audit trail of state transitions for a FOSM record.
10
+ class TransitionHistory < FosmRailsCodingAgent::Tools::Base
11
+ include FosmRailsCodingAgent::Tools::Fosm::ModelResolver
12
+ tool_name "fosm_transition_history"
13
+ description <<~DESC
14
+ Returns the transition audit trail for a FOSM record.
15
+ Shows each state change with event, actor, timestamp, and metadata.
16
+ DESC
17
+
18
+ arguments do
19
+ required(:model).filled(:string).description("Model class name, e.g. 'Invoice'")
20
+ required(:id).filled(:integer).description("Record ID")
21
+ optional(:limit).filled(:integer).description("Max transitions to return (default: 50)")
22
+ end
23
+
24
+ def call(model:, id:, limit: 50)
25
+ klass = resolve_model(model)
26
+ record = klass.find(id)
27
+
28
+ logs = ::Fosm::TransitionLog
29
+ .where(record_type: klass.name, record_id: record.id.to_s)
30
+ .order(created_at: :desc)
31
+ .limit(limit)
32
+
33
+ result = {
34
+ model: klass.name,
35
+ id: record.id,
36
+ current_state: record.state,
37
+ transitions: logs.map do |log|
38
+ entry = {
39
+ event: log.event_name,
40
+ from: log.from_state,
41
+ to: log.to_state,
42
+ actor: log.actor_label || log.actor_type,
43
+ at: log.created_at.iso8601
44
+ }
45
+ entry[:metadata] = log.metadata if log.metadata.present?
46
+ entry[:snapshot] = true if log.state_snapshot.present?
47
+ entry
48
+ end
49
+ }
50
+
51
+ JSON.pretty_generate(result)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "model_resolver"
5
+
6
+ module FosmRailsCodingAgent
7
+ module Tools
8
+ module Fosm
9
+ # Explains why a specific event cannot fire on a record.
10
+ # Returns guard failures, invalid state, terminal state info, etc.
11
+ class WhyBlocked < FosmRailsCodingAgent::Tools::Base
12
+ include FosmRailsCodingAgent::Tools::Fosm::ModelResolver
13
+ tool_name "fosm_why_blocked"
14
+ description <<~DESC
15
+ Explains why a lifecycle event cannot fire on a FOSM record.
16
+ Returns detailed diagnostics: guard failures, invalid transitions,
17
+ terminal state blocks, and valid source states.
18
+ DESC
19
+
20
+ arguments do
21
+ required(:model).filled(:string).description("Model class name, e.g. 'Invoice'")
22
+ required(:id).filled(:integer).description("Record ID")
23
+ required(:event).filled(:string).description("Event name to diagnose, e.g. 'send_invoice'")
24
+ end
25
+
26
+ def call(model:, id:, event:)
27
+ klass = resolve_model(model)
28
+ record = klass.find(id)
29
+ result = record.why_cannot_fire?(event.to_sym)
30
+
31
+ JSON.pretty_generate(result)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FosmRailsCodingAgent
4
+ module Tools
5
+ # Extracts documentation comments from above a constant or method definition.
6
+ # Relies on GetSourceLocation to find the source file and line.
7
+ class GetDocs < Base
8
+ tool_name "get_docs"
9
+ description <<~DESC
10
+ Returns documentation comments for a Ruby constant or method.
11
+
12
+ Examples: `String`, `String#gsub`, `File.executable?`
13
+
14
+ Works for project code and gem dependencies.
15
+ Prefer this over grepping when you know the exact reference.
16
+ DESC
17
+
18
+ arguments do
19
+ required(:reference).filled(:string).description("Constant or method to look up documentation for")
20
+ end
21
+
22
+ def call(reference:)
23
+ file_path, line_number = FosmRailsCodingAgent::Tools::GetSourceLocation.resolve(reference)
24
+ raise NameError, "could not find docs for #{reference}" unless file_path
25
+
26
+ extract_documentation(file_path, line_number) || "No documentation found for #{reference}"
27
+ end
28
+
29
+ private
30
+
31
+ def extract_documentation(file_path, line_number)
32
+ return nil unless File.exist?(file_path)
33
+
34
+ lines = File.readlines(file_path)
35
+ return nil if line_number <= 0 || line_number > lines.length
36
+
37
+ current_line = line_number - 2
38
+ comment_lines = []
39
+
40
+ while current_line >= 0
41
+ line = lines[current_line].chomp.strip
42
+
43
+ if line.start_with?("#")
44
+ comment_lines.unshift(line.sub(/^#\s?/, ""))
45
+ elsif line.empty?
46
+ # skip blank lines between comments
47
+ else
48
+ break
49
+ end
50
+
51
+ current_line -= 1
52
+ end
53
+
54
+ comment_lines.empty? ? nil : comment_lines.join("\n")
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FosmRailsCodingAgent
4
+ module Tools
5
+ # Tails the Rails development log with optional regex filtering.
6
+ class GetLogs < Base
7
+ tool_name "get_logs"
8
+ description <<~DESC
9
+ Returns recent log entries from the Rails development log.
10
+ Use this to check for request logs, errors, or debugging output.
11
+ DESC
12
+
13
+ arguments do
14
+ required(:tail).filled(:integer).description("Number of log lines to return from the end")
15
+ optional(:grep).filled(:string).description("Filter logs with a case-insensitive regex pattern")
16
+ end
17
+
18
+ def call(tail:, grep: nil)
19
+ log_file = Rails.root.join("log", "#{Rails.env}.log")
20
+ return "Log file not found" unless File.exist?(log_file)
21
+
22
+ regex = Regexp.new(grep, Regexp::IGNORECASE) if grep
23
+ matching_lines = []
24
+
25
+ tail_lines(log_file) do |line|
26
+ if regex.nil? || line.match?(regex)
27
+ matching_lines.unshift(line)
28
+ break if matching_lines.size >= tail
29
+ end
30
+ end
31
+
32
+ matching_lines.join
33
+ end
34
+
35
+ private
36
+
37
+ def tail_lines(file_path)
38
+ File.open(file_path, "rb") do |file|
39
+ file.seek(0, IO::SEEK_END)
40
+ file_size = file.pos
41
+ return if file_size == 0
42
+
43
+ buffer_size = [4096, file_size].min
44
+ pos = file_size
45
+ buffer = ""
46
+
47
+ while pos > 0 && buffer.count("\n") < 10_000
48
+ seek_pos = [pos - buffer_size, 0].max
49
+ file.seek(seek_pos)
50
+ chunk = file.read(pos - seek_pos)
51
+ buffer = chunk + buffer
52
+ pos = seek_pos
53
+
54
+ lines = buffer.split("\n")
55
+ buffer = pos > 0 && !buffer.start_with?("\n") ? (lines.shift || "") : ""
56
+
57
+ lines.reverse_each { |line| yield "#{line}\n" unless line.empty? }
58
+ break if pos == 0
59
+ end
60
+
61
+ yield "#{buffer}\n" unless buffer.empty?
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end