agentf 0.3.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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/bin/agentf +8 -0
  3. data/lib/agentf/agent_policy.rb +54 -0
  4. data/lib/agentf/agents/architect.rb +67 -0
  5. data/lib/agentf/agents/base.rb +53 -0
  6. data/lib/agentf/agents/debugger.rb +75 -0
  7. data/lib/agentf/agents/designer.rb +69 -0
  8. data/lib/agentf/agents/documenter.rb +58 -0
  9. data/lib/agentf/agents/explorer.rb +65 -0
  10. data/lib/agentf/agents/reviewer.rb +64 -0
  11. data/lib/agentf/agents/security.rb +84 -0
  12. data/lib/agentf/agents/specialist.rb +68 -0
  13. data/lib/agentf/agents/tester.rb +79 -0
  14. data/lib/agentf/agents.rb +19 -0
  15. data/lib/agentf/cli/architecture.rb +83 -0
  16. data/lib/agentf/cli/arg_parser.rb +50 -0
  17. data/lib/agentf/cli/code.rb +165 -0
  18. data/lib/agentf/cli/install.rb +112 -0
  19. data/lib/agentf/cli/memory.rb +393 -0
  20. data/lib/agentf/cli/metrics.rb +103 -0
  21. data/lib/agentf/cli/router.rb +111 -0
  22. data/lib/agentf/cli/update.rb +204 -0
  23. data/lib/agentf/commands/architecture.rb +183 -0
  24. data/lib/agentf/commands/debugger.rb +238 -0
  25. data/lib/agentf/commands/designer.rb +179 -0
  26. data/lib/agentf/commands/explorer.rb +208 -0
  27. data/lib/agentf/commands/memory_reviewer.rb +186 -0
  28. data/lib/agentf/commands/metrics.rb +272 -0
  29. data/lib/agentf/commands/security_scanner.rb +98 -0
  30. data/lib/agentf/commands/tester.rb +232 -0
  31. data/lib/agentf/commands.rb +17 -0
  32. data/lib/agentf/context_builder.rb +35 -0
  33. data/lib/agentf/installer.rb +580 -0
  34. data/lib/agentf/mcp/server.rb +310 -0
  35. data/lib/agentf/memory.rb +530 -0
  36. data/lib/agentf/packs.rb +74 -0
  37. data/lib/agentf/service/providers.rb +158 -0
  38. data/lib/agentf/tools/component_spec.rb +28 -0
  39. data/lib/agentf/tools/error_analysis.rb +19 -0
  40. data/lib/agentf/tools/file_match.rb +21 -0
  41. data/lib/agentf/tools/test_template.rb +17 -0
  42. data/lib/agentf/tools.rb +12 -0
  43. data/lib/agentf/version.rb +5 -0
  44. data/lib/agentf/workflow_contract.rb +158 -0
  45. data/lib/agentf/workflow_engine.rb +424 -0
  46. data/lib/agentf.rb +87 -0
  47. metadata +164 -0
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "fileutils"
5
+
6
+ module Agentf
7
+ module CLI
8
+ # CLI subcommand for updating provider manifests when the gem version changes.
9
+ #
10
+ # Compares a `.agentf-version` stamp file in each provider directory against
11
+ # `Agentf::VERSION`. If the stamp is missing or outdated, re-runs the
12
+ # installer and writes the new stamp. Skips when already current unless
13
+ # `--force` is given.
14
+ #
15
+ # Usage:
16
+ # agentf update
17
+ # agentf update --force
18
+ # agentf update --provider=opencode,copilot --scope=local
19
+ class Update
20
+ include ArgParser
21
+
22
+ # Maps each provider to the directory where its stamp file lives.
23
+ STAMP_DIRS = {
24
+ "opencode" => ".opencode",
25
+ "copilot" => ".github"
26
+ }.freeze
27
+
28
+ STAMP_FILENAME = ".agentf-version"
29
+
30
+ def initialize(installer_class: Agentf::Installer)
31
+ @installer_class = installer_class
32
+ @options = {
33
+ providers: ["opencode"],
34
+ scope: "all",
35
+ global_root: Dir.home,
36
+ local_root: Dir.pwd,
37
+ force: false
38
+ }
39
+ end
40
+
41
+ def run(args)
42
+ if args.include?("help") || args.include?("--help") || args.include?("-h")
43
+ show_help
44
+ return
45
+ end
46
+
47
+ parse_args(args)
48
+
49
+ roots = roots_for(@options[:scope])
50
+ any_updated = false
51
+
52
+ @options[:providers].each do |provider|
53
+ roots.each do |root|
54
+ updated = update_provider(provider: provider, root: root)
55
+ any_updated = true if updated
56
+ end
57
+ end
58
+
59
+ puts "\nAlready up to date." unless any_updated
60
+ end
61
+
62
+ private
63
+
64
+ def parse_args(args)
65
+ @options[:force] = !args.delete("--force").nil?
66
+
67
+ provider_val = parse_single_option(args, "--provider=") || parse_single_option(args, "-p=")
68
+ if provider_val
69
+ providers = provider_val.split(",").map { |item| item.strip.downcase }.reject(&:empty?)
70
+ @options[:providers] = providers == ["all"] ? Agentf::Installer::PROVIDER_LAYOUTS.keys : providers
71
+ end
72
+
73
+ scope_val = parse_single_option(args, "--scope=") || parse_single_option(args, "-s=")
74
+ @options[:scope] = scope_val.downcase if scope_val
75
+
76
+ global_root = parse_single_option(args, "--global-root=")
77
+ @options[:global_root] = File.expand_path(global_root) if global_root
78
+
79
+ local_root = parse_single_option(args, "--local-root=")
80
+ @options[:local_root] = File.expand_path(local_root) if local_root
81
+ end
82
+
83
+ def roots_for(scope)
84
+ case scope
85
+ when "global"
86
+ [@options[:global_root]]
87
+ when "local"
88
+ [@options[:local_root]]
89
+ else
90
+ [@options[:global_root], @options[:local_root]]
91
+ end
92
+ end
93
+
94
+ def update_provider(provider:, root:)
95
+ stamp_dir = STAMP_DIRS.fetch(provider) do
96
+ $stderr.puts "Unknown provider: #{provider}"
97
+ return false
98
+ end
99
+
100
+ stamp_path = File.join(root, stamp_dir, STAMP_FILENAME)
101
+ installed_version = read_stamp(stamp_path)
102
+
103
+ if installed_version == Agentf::VERSION && !@options[:force]
104
+ puts "#{provider} (#{shorten(root)}): up to date (v#{Agentf::VERSION})"
105
+ return false
106
+ end
107
+
108
+ migrate_old_files(root, provider) if @options[:force]
109
+
110
+ action = installed_version ? "Updating #{installed_version} -> #{Agentf::VERSION}" : "Installing v#{Agentf::VERSION}"
111
+ action = "Force reinstalling v#{Agentf::VERSION}" if @options[:force] && installed_version == Agentf::VERSION
112
+ puts "#{provider} (#{shorten(root)}): #{action}"
113
+
114
+ installer = @installer_class.new(
115
+ global_root: root,
116
+ local_root: root
117
+ )
118
+
119
+ results = installer.install(
120
+ providers: [provider],
121
+ scope: "local"
122
+ )
123
+
124
+ results.each do |result|
125
+ puts " #{result.fetch('status').upcase}: #{Pathname.new(result.fetch('path')).cleanpath}"
126
+ end
127
+
128
+ write_stamp(stamp_path, Agentf::VERSION)
129
+ puts " Stamp: #{Agentf::VERSION} -> #{shorten(stamp_path)}"
130
+ true
131
+ end
132
+
133
+ def migrate_old_files(root, provider)
134
+ return unless provider == "opencode"
135
+
136
+ opencode_dir = File.join(root, ".opencode")
137
+ return unless Dir.exist?(opencode_dir)
138
+
139
+ old_files = [
140
+ File.join(opencode_dir, "tools", "agentf-tools.ts"),
141
+ File.join(opencode_dir, "agents", "WORKFLOW_ENGINE.md"),
142
+ File.join(opencode_dir, "memory", "REDIS_SCHEMA.md")
143
+ ]
144
+
145
+ old_agent_names = %w[EXPLORER ARCHITECT DESIGNER DEBUGGER REVIEWER TESTER DOCUMENTER SECURITY SPECIALIST]
146
+ old_files.concat(old_agent_names.map { |name| File.join(opencode_dir, "agents", "#{name}.md") })
147
+
148
+ old_command_names = %w[explorer tester metrics security_scanner memory_reviewer designer debugger architecture]
149
+ old_files.concat(old_command_names.map { |name| File.join(opencode_dir, "commands", "#{name}.md") })
150
+
151
+ removed_count = 0
152
+ old_files.each do |file|
153
+ next unless File.exist?(file)
154
+
155
+ File.delete(file)
156
+ puts " REMOVED: #{shorten(file)}"
157
+ removed_count += 1
158
+ end
159
+
160
+ puts " Migration: removed #{removed_count} old files" if removed_count > 0
161
+ end
162
+
163
+ def read_stamp(path)
164
+ return nil unless File.exist?(path)
165
+
166
+ File.read(path).strip
167
+ rescue SystemCallError
168
+ nil
169
+ end
170
+
171
+ def write_stamp(path, version)
172
+ FileUtils.mkdir_p(File.dirname(path))
173
+ File.write(path, "#{version}\n")
174
+ end
175
+
176
+ def shorten(path)
177
+ home = Dir.home
178
+ path.start_with?(home) ? path.sub(home, "~") : path
179
+ end
180
+
181
+ def show_help
182
+ puts <<~HELP
183
+ Usage: agentf update [options]
184
+
185
+ Regenerates provider manifests when the agentf gem version changes.
186
+ Compares a .agentf-version stamp file against the current version.
187
+ Skips providers that are already up to date unless --force is used.
188
+
189
+ Options:
190
+ --provider=LIST Providers: opencode,copilot,all (default: opencode)
191
+ --scope=SCOPE Update scope: global|local|all (default: all)
192
+ --global-root=PATH Root for global installs (default: $HOME)
193
+ --local-root=PATH Root for local installs (default: current directory)
194
+ --force Regenerate even if version matches
195
+
196
+ Examples:
197
+ agentf update
198
+ agentf update --force
199
+ agentf update --provider=opencode,copilot --scope=local
200
+ HELP
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Agentf
6
+ module Commands
7
+ class Architecture
8
+ NAME = "architecture"
9
+
10
+ def self.manifest
11
+ {
12
+ "name" => NAME,
13
+ "description" => "Analyze architecture layers, callback risks, and incremental adoption opportunities.",
14
+ "commands" => [
15
+ { "name" => "analyze_layers", "type" => "function" },
16
+ { "name" => "analyze_callbacks", "type" => "function" },
17
+ { "name" => "find_god_objects", "type" => "function" },
18
+ { "name" => "review_layer_violations", "type" => "function" },
19
+ { "name" => "plan_gradual_adoption", "type" => "function" }
20
+ ]
21
+ }
22
+ end
23
+
24
+ def initialize(base_path: nil)
25
+ @base_path = Pathname.new(base_path || Agentf.config.base_path)
26
+ end
27
+
28
+ def analyze_layers
29
+ files = @base_path.glob("**/*.rb").select(&:file?)
30
+ buckets = {
31
+ "models" => 0,
32
+ "controllers" => 0,
33
+ "services" => 0,
34
+ "queries" => 0,
35
+ "presenters" => 0,
36
+ "concerns" => 0,
37
+ "other" => 0
38
+ }
39
+
40
+ files.each do |file|
41
+ rel = file.relative_path_from(@base_path).to_s
42
+ bucket = classify_layer(rel)
43
+ buckets[bucket] += 1
44
+ end
45
+
46
+ {
47
+ "total_files" => files.length,
48
+ "layers" => buckets,
49
+ "layer_balance_score" => layer_balance_score(buckets)
50
+ }
51
+ end
52
+
53
+ def analyze_callbacks(limit: 20)
54
+ files = @base_path.glob("app/models/**/*.rb").select(&:file?)
55
+ findings = []
56
+
57
+ files.each do |file|
58
+ text = safe_read(file)
59
+ count = text.scan(/\b(before|after|around)_(save|create|update|validation|commit|destroy)\b/).length
60
+ next if count.zero?
61
+
62
+ findings << {
63
+ "file" => file.relative_path_from(@base_path).to_s,
64
+ "callbacks" => count,
65
+ "risk" => count >= 4 ? "high" : (count >= 2 ? "medium" : "low")
66
+ }
67
+ end
68
+
69
+ sorted = findings.sort_by { |item| -item["callbacks"] }.first(limit)
70
+ { "count" => sorted.length, "findings" => sorted }
71
+ end
72
+
73
+ def find_god_objects(limit: 20)
74
+ files = @base_path.glob("app/**/*.rb").select(&:file?)
75
+ findings = files.map do |file|
76
+ text = safe_read(file)
77
+ methods = text.scan(/^\s*def\s+/).length
78
+ lines = text.lines.length
79
+ score = (methods * lines).to_i
80
+
81
+ {
82
+ "file" => file.relative_path_from(@base_path).to_s,
83
+ "methods" => methods,
84
+ "lines" => lines,
85
+ "score" => score
86
+ }
87
+ end
88
+
89
+ top = findings.sort_by { |item| -item["score"] }.first(limit)
90
+ { "count" => top.length, "findings" => top }
91
+ end
92
+
93
+ def review_layer_violations(limit: 50)
94
+ files = @base_path.glob("app/**/*.rb").select(&:file?)
95
+ violations = []
96
+
97
+ files.each do |file|
98
+ rel = file.relative_path_from(@base_path).to_s
99
+ text = safe_read(file)
100
+
101
+ if rel.start_with?("app/models/") && text.include?("render ")
102
+ violations << violation(rel, "model_renders_view", "Model references rendering concerns")
103
+ end
104
+
105
+ if rel.start_with?("app/controllers/") && text.include?("ActiveRecord::Base")
106
+ violations << violation(rel, "controller_raw_active_record", "Controller references ActiveRecord::Base directly")
107
+ end
108
+
109
+ if rel.start_with?("app/services/") && text.include?("params[")
110
+ violations << violation(rel, "service_params_coupling", "Service appears tightly coupled to controller params")
111
+ end
112
+ end
113
+
114
+ {
115
+ "count" => [violations.length, limit].min,
116
+ "violations" => violations.first(limit)
117
+ }
118
+ end
119
+
120
+ def plan_gradual_adoption(goal: "improve architecture boundaries")
121
+ layer_report = analyze_layers
122
+ callback_report = analyze_callbacks(limit: 5)
123
+ god_report = find_god_objects(limit: 5)
124
+
125
+ steps = [
126
+ "Baseline current architecture metrics and annotate high-risk hotspots.",
127
+ "Prioritize top callback-heavy models and extract one concern/service seam at a time.",
128
+ "Refactor top god objects behind explicit boundaries with tests per extraction.",
129
+ "Add review gate checks to prevent reintroduction of known violations.",
130
+ "Track trend metrics weekly and iterate toward target layer balance."
131
+ ]
132
+
133
+ {
134
+ "goal" => goal,
135
+ "baseline" => {
136
+ "layers" => layer_report,
137
+ "callbacks" => callback_report,
138
+ "god_objects" => god_report
139
+ },
140
+ "steps" => steps
141
+ }
142
+ end
143
+
144
+ private
145
+
146
+ def classify_layer(path)
147
+ return "models" if path.start_with?("app/models/")
148
+ return "controllers" if path.start_with?("app/controllers/")
149
+ return "services" if path.start_with?("app/services/")
150
+ return "queries" if path.include?("/queries/")
151
+ return "presenters" if path.include?("/presenters/")
152
+ return "concerns" if path.include?("/concerns/")
153
+
154
+ "other"
155
+ end
156
+
157
+ def layer_balance_score(buckets)
158
+ core = %w[models controllers services queries presenters concerns]
159
+ values = core.map { |key| buckets[key] }
160
+ max = values.max.to_f
161
+ min = values.min.to_f
162
+ return 1.0 if max.zero?
163
+
164
+ (1.0 - ((max - min) / max)).round(4)
165
+ end
166
+
167
+ def safe_read(path)
168
+ path.read
169
+ rescue StandardError
170
+ ""
171
+ end
172
+
173
+ def violation(file, code, message)
174
+ {
175
+ "file" => file,
176
+ "code" => code,
177
+ "message" => message,
178
+ "severity" => "warn"
179
+ }
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Agentf
6
+ module Commands
7
+ class Debugger
8
+ NAME = "debugger"
9
+
10
+ def self.manifest
11
+ {
12
+ "name" => NAME,
13
+ "description" => "Parse errors, analyze logs, and suggest fixes for bugs.",
14
+ "commands" => [
15
+ { "name" => "parse_error", "type" => "function" },
16
+ { "name" => "analyze_logs", "type" => "function" },
17
+ { "name" => "suggest_fix", "type" => "function" },
18
+ { "name" => "cluster_errors", "type" => "function" }
19
+ ]
20
+ }
21
+ end
22
+
23
+ ERROR_PATTERNS = {
24
+ "ImportError" => {
25
+ "possible_causes" => [
26
+ "Module not installed",
27
+ "Wrong import path",
28
+ "Circular import",
29
+ "Missing __init__.py"
30
+ ],
31
+ "fix_template" => "Check package installation and import path"
32
+ },
33
+ "TypeError" => {
34
+ "possible_causes" => [
35
+ "Passing wrong type to function",
36
+ "Undefined variable",
37
+ "Calling non-callable"
38
+ ],
39
+ "fix_template" => "Verify argument types match function signature"
40
+ },
41
+ "NoMethodError" => {
42
+ "possible_causes" => [
43
+ "Method not defined",
44
+ "Typo in method name",
45
+ "Wrong object type"
46
+ ],
47
+ "fix_template" => "Check method exists on object"
48
+ },
49
+ "NameError" => {
50
+ "possible_causes" => [
51
+ "Variable not defined",
52
+ "Using before declaration",
53
+ "Scope issue"
54
+ ],
55
+ "fix_template" => "Check variable is defined before use"
56
+ },
57
+ "SyntaxError" => {
58
+ "possible_causes" => [
59
+ "Missing bracket/parenthesis",
60
+ "Invalid syntax",
61
+ "Indentation error"
62
+ ],
63
+ "fix_template" => "Check syntax at error location"
64
+ },
65
+ "NetworkError" => {
66
+ "possible_causes" => [
67
+ "API endpoint down",
68
+ "Network connectivity",
69
+ "CORS issue",
70
+ "Timeout"
71
+ ],
72
+ "fix_template" => "Verify API endpoint and network connectivity"
73
+ }
74
+ }.freeze
75
+
76
+ def initialize(base_path: nil)
77
+ @base_path = base_path || Agentf.config.base_path
78
+ end
79
+
80
+ # Parse error text and extract structured information
81
+ def parse_error(error_text)
82
+ error_type = "Unknown"
83
+ message = error_text
84
+ location = "unknown"
85
+
86
+ # Try Ruby pattern
87
+ if (match = error_text.match(/(?<type>\w+(?:Error|Exception)):\s*(?<message>[^\n]+?)(?:\s+from\s+(?<file>[^:]+):(?<line>\d+))?$/m))
88
+ error_type = match[:type]
89
+ message = match[:message].strip
90
+ location = "#{match[:file]}:#{match[:line]}" if match[:file]
91
+ end
92
+
93
+ # Try Python pattern
94
+ if error_type == "Unknown" && (match = error_text.match(/(\w+Error):\s*([^\n]+?)(?:\s+File\s+"([^"]+)",\s+line\s+(\d+))?$/m))
95
+ error_type = match[1]
96
+ message = match[2].strip
97
+ location = "#{match[3]}:#{match[4]}" if match[3]
98
+ end
99
+
100
+ # Try JS pattern
101
+ if error_type == "Unknown" && (match = error_text.match(/(\w+Error):\s*([^\n]+?)(?:\s+at\s+.+?\s+\((.+?):(\d+):(\d+)\))?$/m))
102
+ error_type = match[1]
103
+ message = match[2].strip
104
+ location = "#{match[3]}:#{match[4]}" if match[3]
105
+ end
106
+
107
+ error_info = ERROR_PATTERNS[error_type] || {
108
+ "possible_causes" => ["Unknown error source"],
109
+ "fix_template" => "Investigate error context"
110
+ }
111
+
112
+ stack_trace = parse_stack_trace(error_text)
113
+
114
+ Agentf::Tools::ErrorAnalysis.new(
115
+ error_type: error_type,
116
+ message: message[0..199],
117
+ location: location,
118
+ possible_causes: error_info["possible_causes"],
119
+ suggested_fix: error_info["fix_template"],
120
+ stack_trace: stack_trace
121
+ )
122
+ end
123
+
124
+ # Analyze log files for errors/warnings
125
+ def analyze_logs(log_file: nil, num_lines: 100)
126
+ errors = []
127
+ warnings = []
128
+
129
+ log_paths = []
130
+ if log_file
131
+ log_paths << Pathname.new(@base_path) + log_file
132
+ else
133
+ log_paths << Pathname.new(@base_path).join("logs", "app.log")
134
+ log_paths << Pathname.new(@base_path).join("log", "app.log")
135
+ log_paths << Pathname.new(@base_path).join("app.log")
136
+ end
137
+
138
+ content = ""
139
+ log_paths.each do |path|
140
+ if path.exist?
141
+ lines = path.read.split("\n").last(num_lines)
142
+ content = lines.join("\n")
143
+ break
144
+ end
145
+ end
146
+
147
+ return { "errors" => [], "warnings" => [], "summary" => "No log file found" } if content.empty?
148
+
149
+ content.lines.each do |line|
150
+ errors << line.strip if line =~ /\b(ERROR|Exception|Failed)\b/i
151
+ warnings << line.strip if line =~ /\b(WARN|WARNING)\b/i
152
+ end
153
+
154
+ {
155
+ "errors" => errors.last(20),
156
+ "warnings" => warnings.last(20),
157
+ "summary" => "Found #{errors.size} errors and #{warnings.size} warnings in recent logs"
158
+ }
159
+ end
160
+
161
+ # Generate fix suggestion based on error analysis
162
+ def suggest_fix(analysis, source_code: nil)
163
+ suggestions = []
164
+
165
+ suggestions << analysis.suggested_fix
166
+
167
+ msg_lower = analysis.message.downcase
168
+ suggestions << "Check for nil values before use" if msg_lower.include?("nil") || msg_lower.include?("none")
169
+ suggestions << "Verify variable is defined before access" if msg_lower.include?("undefined") || msg_lower.include?("not defined")
170
+ suggestions << "Consider increasing timeout or checking service availability" if msg_lower.include?("timeout")
171
+
172
+ suggestions << "Review code at #{analysis.location}" if source_code && analysis.location != "unknown"
173
+
174
+ historical = historical_fix_hints(analysis)
175
+ suggestions.concat(historical) unless historical.empty?
176
+
177
+ suggestions.map { |s| "- #{s}" }.join("\n")
178
+ end
179
+
180
+ def cluster_errors(errors)
181
+ clusters = Hash.new { |h, k| h[k] = { "count" => 0, "examples" => [] } }
182
+
183
+ Array(errors).each do |error_text|
184
+ analysis = parse_error(error_text.to_s)
185
+ key = analysis.error_type
186
+ clusters[key]["count"] += 1
187
+ clusters[key]["examples"] << analysis.message if clusters[key]["examples"].length < 3
188
+ end
189
+
190
+ clusters
191
+ end
192
+
193
+ private
194
+
195
+ def parse_stack_trace(error_text)
196
+ frames = []
197
+
198
+ # Ruby stack trace
199
+ error_text.scan(/from\s+([^:]+):(\d+):in\s+`(.+?)'/) do |file, line, func|
200
+ frames << { "file" => file, "line" => line, "function" => func }
201
+ end
202
+
203
+ # Python stack trace
204
+ error_text.scan(/File\s+"([^"]+)",\s+line\s+(\d+),\s+in\s+(\w+)/) do |file, line, func|
205
+ frames << { "file" => file, "line" => line, "function" => func }
206
+ end
207
+
208
+ # JS stack trace
209
+ error_text.scan(/at\s+(?:(\w+)\s+)?\(?(.+?):(\d+):(\d+)\)?/) do |func, file, line, _col|
210
+ frames << { "function" => func || "anonymous", "file" => file, "line" => line }
211
+ end
212
+
213
+ frames
214
+ end
215
+
216
+ def historical_fix_hints(analysis)
217
+ memory = Agentf::Memory::RedisMemory.new(project: Agentf.config.project_name)
218
+ incidents = memory.get_memories_by_type(type: "incident", limit: 25)
219
+ return [] if incidents.empty?
220
+
221
+ matched = incidents.select do |incident|
222
+ haystack = [incident["title"], incident["description"], incident["context"]].compact.join(" ").downcase
223
+ haystack.include?(analysis.error_type.downcase)
224
+ end
225
+
226
+ return [] if matched.empty?
227
+
228
+ matched.first(2).map do |incident|
229
+ "Try prior incident fix: #{incident['title']}"
230
+ end
231
+ rescue StandardError
232
+ []
233
+ ensure
234
+ memory&.close
235
+ end
236
+ end
237
+ end
238
+ end