agent_c 2.9

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 (65) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +10 -0
  3. data/.ruby-version +1 -0
  4. data/CLAUDE.md +21 -0
  5. data/README.md +360 -0
  6. data/Rakefile +16 -0
  7. data/TODO.md +105 -0
  8. data/agent_c.gemspec +38 -0
  9. data/docs/batch.md +503 -0
  10. data/docs/chat-methods.md +156 -0
  11. data/docs/cost-reporting.md +86 -0
  12. data/docs/pipeline-tips-and-tricks.md +453 -0
  13. data/docs/session-configuration.md +274 -0
  14. data/docs/testing.md +747 -0
  15. data/docs/tools.md +103 -0
  16. data/docs/versioned-store.md +840 -0
  17. data/lib/agent_c/agent/chat.rb +211 -0
  18. data/lib/agent_c/agent/chat_response.rb +38 -0
  19. data/lib/agent_c/agent/chats/anthropic_bedrock.rb +48 -0
  20. data/lib/agent_c/batch.rb +102 -0
  21. data/lib/agent_c/configs/repo.rb +90 -0
  22. data/lib/agent_c/context.rb +56 -0
  23. data/lib/agent_c/costs/data.rb +39 -0
  24. data/lib/agent_c/costs/report.rb +219 -0
  25. data/lib/agent_c/db/store.rb +162 -0
  26. data/lib/agent_c/errors.rb +19 -0
  27. data/lib/agent_c/pipeline.rb +152 -0
  28. data/lib/agent_c/pipelines/agent.rb +219 -0
  29. data/lib/agent_c/processor.rb +98 -0
  30. data/lib/agent_c/prompts.yml +53 -0
  31. data/lib/agent_c/schema.rb +71 -0
  32. data/lib/agent_c/session.rb +206 -0
  33. data/lib/agent_c/store.rb +72 -0
  34. data/lib/agent_c/test_helpers.rb +173 -0
  35. data/lib/agent_c/tools/dir_glob.rb +46 -0
  36. data/lib/agent_c/tools/edit_file.rb +114 -0
  37. data/lib/agent_c/tools/file_metadata.rb +43 -0
  38. data/lib/agent_c/tools/git_status.rb +30 -0
  39. data/lib/agent_c/tools/grep.rb +119 -0
  40. data/lib/agent_c/tools/paths.rb +36 -0
  41. data/lib/agent_c/tools/read_file.rb +94 -0
  42. data/lib/agent_c/tools/run_rails_test.rb +87 -0
  43. data/lib/agent_c/tools.rb +61 -0
  44. data/lib/agent_c/utils/git.rb +87 -0
  45. data/lib/agent_c/utils/shell.rb +58 -0
  46. data/lib/agent_c/version.rb +5 -0
  47. data/lib/agent_c.rb +32 -0
  48. data/lib/versioned_store/base.rb +314 -0
  49. data/lib/versioned_store/config.rb +26 -0
  50. data/lib/versioned_store/stores/schema.rb +127 -0
  51. data/lib/versioned_store/version.rb +5 -0
  52. data/lib/versioned_store.rb +5 -0
  53. data/template/Gemfile +9 -0
  54. data/template/Gemfile.lock +152 -0
  55. data/template/README.md +61 -0
  56. data/template/Rakefile +50 -0
  57. data/template/bin/rake +27 -0
  58. data/template/lib/autoload.rb +10 -0
  59. data/template/lib/config.rb +59 -0
  60. data/template/lib/pipeline.rb +19 -0
  61. data/template/lib/prompts.yml +57 -0
  62. data/template/lib/store.rb +17 -0
  63. data/template/test/pipeline_test.rb +221 -0
  64. data/template/test/test_helper.rb +18 -0
  65. metadata +194 -0
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ostruct"
4
+ require "json-schema"
5
+ require "tmpdir"
6
+
7
+ module AgentC
8
+ module TestHelpers
9
+ # Helper to create a test session with minimal required parameters
10
+ def test_session(
11
+ agent_db_path: File.join(Dir.mktmpdir, "db.sqlite"),
12
+ **overrides
13
+ )
14
+ Session.new(
15
+ agent_db_path:,
16
+ project: "test_project",
17
+ **overrides
18
+ )
19
+ end
20
+
21
+ # DummyChat that maps input_text => output_text for testing
22
+ # Use this with Session.new() by passing it as a chat_provider or record parameter
23
+ class DummyChat
24
+ attr_reader :id, :messages_history, :tools_received, :prompts_received, :invocations
25
+
26
+ def initialize(
27
+ responses: {},
28
+ prompts: [],
29
+ tools: [],
30
+ cached_prompts: [],
31
+ workspace_dir: nil,
32
+ record: nil,
33
+ session: nil,
34
+ **_options
35
+ )
36
+ @responses = responses
37
+ @id = "test-chat-#{rand(1000)}"
38
+ @messages_history = []
39
+ @prompts_received = prompts
40
+ @tools_received = tools
41
+ @on_end_message_blocks = []
42
+ end
43
+
44
+ def ask(input_text)
45
+ # Try to find a matching response
46
+ _, output = (
47
+ @responses.find do |key, value|
48
+ (key.is_a?(Regexp) && input_text.match?(key)) ||
49
+ (key.is_a?(Proc) && key.call(input_text)) ||
50
+ (key == input_text)
51
+ end
52
+ )
53
+
54
+ output_text = (
55
+ if output.respond_to?(:call)
56
+ output.call
57
+ else
58
+ output
59
+ end
60
+ )
61
+
62
+ raise "No response configured for: #{input_text.inspect}" if output_text.nil?
63
+
64
+ # Create a mock message with the input
65
+ user_message = OpenStruct.new(
66
+ role: :user,
67
+ content: input_text,
68
+ to_llm: OpenStruct.new(to_h: { role: :user, content: input_text })
69
+ )
70
+
71
+ # Create a mock response message
72
+ response_message = OpenStruct.new(
73
+ role: :assistant,
74
+ content: output_text,
75
+ to_llm: OpenStruct.new(to_h: { role: :assistant, content: output_text })
76
+ )
77
+
78
+ @messages_history << user_message
79
+ @messages_history << response_message
80
+
81
+ # Call all on_end_message hooks
82
+ @on_end_message_blocks.each { |block| block.call(response_message) }
83
+
84
+ response_message
85
+ end
86
+
87
+ def get(input_text, schema: nil, **options)
88
+ # Similar to ask, but returns parsed JSON as a Hash for structured responses
89
+ response_message = ask(input_text)
90
+
91
+ json_schema = schema&.to_json_schema&.fetch(:schema)
92
+
93
+ # Parse the response content as JSON
94
+ begin
95
+ result = JSON.parse(response_message.content)
96
+ if json_schema.nil? || JSON::Validator.validate(json_schema, result)
97
+ result
98
+ else
99
+ raise "Failed to get valid response"
100
+ end
101
+ rescue JSON::ParserError
102
+ # If not valid JSON, wrap in a hash
103
+ { "result" => response_message.content }
104
+ end
105
+ end
106
+
107
+ def messages(...)
108
+ @messages_history
109
+ end
110
+
111
+ def with_tools(*tools)
112
+ @tools_received = tools.flatten
113
+ self
114
+ end
115
+
116
+ def on_new_message(&block)
117
+ self
118
+ end
119
+
120
+ def on_end_message(&block)
121
+ @on_end_message_blocks << block
122
+ self
123
+ end
124
+
125
+ def on_tool_call(&block)
126
+ self
127
+ end
128
+
129
+ def on_tool_result(&block)
130
+ self
131
+ end
132
+ end
133
+
134
+ # DummyGit for testing git operations without actual git commands
135
+ # Use this by passing it as the git parameter to Pipeline.call
136
+ class DummyGit
137
+ attr_reader :invocations
138
+
139
+ def initialize(workspace_dir)
140
+ @workspace_dir = workspace_dir
141
+ @invocations = []
142
+ end
143
+
144
+ def uncommitted_changes?
145
+ @has_changes ||= false
146
+ end
147
+
148
+ def commit_all(message)
149
+ @invocations << {
150
+ method: :commit_all,
151
+ args: [message],
152
+ params: {}
153
+ }
154
+ end
155
+
156
+ def simulate_file_created!
157
+ @has_changes = true
158
+ end
159
+
160
+ def method_missing(method, *args, **params)
161
+ @invocations << {
162
+ method:,
163
+ args:,
164
+ params:,
165
+ }
166
+ end
167
+
168
+ def respond_to_missing?(method, include_private = false)
169
+ true
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentC
4
+ module Tools
5
+ class DirGlob < RubyLLM::Tool
6
+ description("Find files in a directory using a ruby-compatible glob pattern.")
7
+
8
+ params do
9
+ string(
10
+ :glob_pattern,
11
+ description: "Only returns children paths of the current directory"
12
+ )
13
+ end
14
+
15
+ attr_reader :workspace_dir
16
+ def initialize(workspace_dir: nil, **)
17
+ raise ArgumentError, "workspace_dir is required" unless workspace_dir
18
+ @workspace_dir = workspace_dir
19
+ end
20
+
21
+ def execute(glob_pattern:, **params)
22
+ if params.any?
23
+ return "The following params were passed but are not allowed: #{params.keys.join(",")}"
24
+ end
25
+
26
+ unless Paths.allowed?(workspace_dir, glob_pattern)
27
+ return "Path: #{glob_pattern} not acceptable. Must be a child of directory: #{workspace_dir}."
28
+ end
29
+
30
+ results = (
31
+ Dir
32
+ .glob(File.join(workspace_dir, glob_pattern))
33
+ .select { Paths.allowed?(workspace_dir, _1) }
34
+ )
35
+
36
+ warning = (
37
+ if results.count > 30
38
+ "Returning 30 of #{results.count} results"
39
+ end
40
+ )
41
+
42
+ [warning, results.take(30).to_json].compact.join("\n")
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentC
4
+ module Tools
5
+ class EditFile < RubyLLM::Tool
6
+ description "Edits a file with various operations: overwrite entire file, replace specific lines, insert at position, or append"
7
+
8
+ params do
9
+ string(
10
+ :path,
11
+ description: "Path to file. Must be a child of current directory.",
12
+ required: true
13
+ )
14
+ string(
15
+ :mode,
16
+ description: "Operation mode: 'overwrite' (replace entire file), 'replace_lines' (replace line range), 'insert_at_line' (insert before specified line), 'append' (add to end)",
17
+ enum: ["overwrite", "replace_lines", "insert_at_line", "append", "delete"],
18
+ required: true
19
+ )
20
+ string(
21
+ :content,
22
+ description: "The content to write, insert, or append. Can be multi-line.",
23
+ required: true
24
+ )
25
+ integer(
26
+ :start_line,
27
+ description: <<~TXT,
28
+ For replace_lines: first line to replace, inclusive (1-indexed).
29
+ For insert_at_line: line before which to insert (1=start of file, 999999=end of file).
30
+ TXT
31
+ required: false
32
+ )
33
+ integer(
34
+ :end_line,
35
+ description: "For replace_lines only: last line to replace, inclusive (1-indexed).",
36
+ required: false
37
+ )
38
+ end
39
+
40
+ attr_reader :workspace_dir
41
+ def initialize(workspace_dir: nil, **)
42
+ raise ArgumentError, "workspace_dir is required" unless workspace_dir
43
+ @workspace_dir = workspace_dir
44
+ end
45
+
46
+ def execute(path:, mode:, content:, start_line: nil, end_line: nil, **params)
47
+ if params.any?
48
+ return "The following params were passed but are not allowed: #{params.keys.join(",")}"
49
+ end
50
+
51
+ unless Paths.allowed?(workspace_dir, path)
52
+ return "Path: #{path} not acceptable. Must be a child of directory: #{workspace_dir}."
53
+ end
54
+
55
+ workspace_path = Paths.relative_to_dir(workspace_dir, path)
56
+
57
+ case mode
58
+ when "delete"
59
+ FileUtils.rm_f(path)
60
+ when "overwrite"
61
+ FileUtils.mkdir_p(File.dirname(workspace_path))
62
+ File.write(workspace_path, content)
63
+
64
+ when "replace_lines"
65
+ return "start_line and end_line required for replace_lines mode" unless start_line && end_line
66
+ return "start_line must be <= end_line" if start_line > end_line
67
+
68
+ lines = File.exist?(workspace_path) ? File.readlines(workspace_path, chomp: true) : []
69
+
70
+ # Convert to 0-indexed
71
+ start_idx = start_line - 1
72
+ end_idx = end_line - 1
73
+
74
+ # Replace the range with new content lines
75
+ new_lines = content.split("\n")
76
+ lines[start_idx..end_idx] = new_lines
77
+
78
+ File.write(workspace_path, lines.join("\n") + "\n")
79
+
80
+ when "insert_at_line"
81
+ return "start_line required for insert_at_line mode" unless start_line
82
+
83
+ lines = File.exist?(workspace_path) ? File.readlines(workspace_path, chomp: true) : []
84
+
85
+ # Insert before the specified line (1-indexed)
86
+ insert_idx = start_line - 1
87
+ insert_idx = [0, [insert_idx, lines.length].min].max # Clamp to valid range
88
+
89
+ new_lines = content.split("\n")
90
+ lines.insert(insert_idx, *new_lines)
91
+
92
+ File.write(workspace_path, lines.join("\n") + "\n")
93
+
94
+ when "append"
95
+ FileUtils.mkdir_p(File.dirname(workspace_path))
96
+ File.open(workspace_path, "a") do |f|
97
+ f.write(content)
98
+ f.write("\n") unless content.end_with?("\n")
99
+ end
100
+ end
101
+
102
+ # Run syntax check for Ruby files
103
+ if workspace_path.end_with?(".rb")
104
+ syntax_check = `ruby -c #{workspace_path} 2>&1`
105
+ unless $?.success?
106
+ return "File successfully edited, but syntax errors were found:\n#{syntax_check}"
107
+ end
108
+ end
109
+
110
+ true
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentC
4
+ module Tools
5
+ class FileMetadata < RubyLLM::Tool
6
+ description "Returns metadata of a file, including line-count, mtime"
7
+
8
+ params do
9
+ string(
10
+ :path,
11
+ description: "Path to file. Must be a child of current directory."
12
+ )
13
+ end
14
+
15
+ attr_reader :workspace_dir
16
+ def initialize(workspace_dir: nil, **)
17
+ raise ArgumentError, "workspace_dir is required" unless workspace_dir
18
+ @workspace_dir = workspace_dir
19
+ end
20
+
21
+ def execute(path:, line_range_start: 0, line_range_end: nil, **params)
22
+ if params.any?
23
+ return "The following params were passed but are not allowed: #{params.keys.join(",")}"
24
+ end
25
+
26
+ unless Paths.allowed?(workspace_dir, path)
27
+ return "Path: #{path} not acceptable. Must be a child of directory: #{workspace_dir}."
28
+ end
29
+
30
+ workspace_path = Paths.relative_to_dir(workspace_dir, path)
31
+
32
+ unless File.exist?(workspace_path)
33
+ return "File not found"
34
+ end
35
+
36
+ {
37
+ mtime: File.mtime(workspace_path),
38
+ lines: File.foreach(workspace_path).count,
39
+ }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module AgentC
6
+ module Tools
7
+ class GitStatus < RubyLLM::Tool
8
+ description <<~DESC
9
+ Return the current git status
10
+ DESC
11
+
12
+ params do
13
+ end
14
+
15
+ attr_reader :workspace_dir
16
+ def initialize(workspace_dir: nil, **)
17
+ raise ArgumentError, "workspace_dir is required" unless workspace_dir
18
+ @workspace_dir = workspace_dir
19
+ end
20
+
21
+ def execute(**params)
22
+ if params.any?
23
+ return "The following params were passed but are not allowed: #{params.keys.join(",")}"
24
+ end
25
+
26
+ Utils::Git.new(workspace_dir).status
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module AgentC
6
+ module Tools
7
+ class Grep < RubyLLM::Tool
8
+ description <<~DESC
9
+ Searches for patterns in files using git grep. Returns matching lines
10
+ with file paths and line numbers.
11
+ DESC
12
+
13
+ params do
14
+ string(
15
+ :pattern,
16
+ description: <<~DESC,
17
+ The regex pattern to search for. Use standard regex syntax.
18
+ DESC
19
+ required: true
20
+ )
21
+ string(
22
+ :file_pattern,
23
+ description: <<~DESC,
24
+ Optional glob pattern to limit search to specific files
25
+ (e.g., '*.rb', 'app/**/*.js'). If omitted, searches all files.
26
+ DESC
27
+ required: false
28
+ )
29
+ boolean(
30
+ :ignore_case,
31
+ description: <<~DESC,
32
+ If true, performs case-insensitive search. Default is false.
33
+ DESC
34
+ required: false
35
+ )
36
+ integer(
37
+ :context_lines,
38
+ description: <<~DESC,
39
+ Number of context lines to show before and after each match.
40
+ Default is 0.
41
+ DESC
42
+ required: false
43
+ )
44
+ integer(
45
+ :line_range_start,
46
+ description: <<~DESC,
47
+ Return results starting from this line number (1-indexed).
48
+ Default is 1.
49
+ DESC
50
+ required: false
51
+ )
52
+ integer(
53
+ :line_range_end,
54
+ description: <<~DESC,
55
+ Return results up to and including this line number (1-indexed).
56
+ If omitted, returns to the end.
57
+ DESC
58
+ required: false
59
+ )
60
+ end
61
+
62
+ attr_reader :workspace_dir
63
+ def initialize(workspace_dir: nil, **)
64
+ raise ArgumentError, "workspace_dir is required" unless workspace_dir
65
+ @workspace_dir = workspace_dir
66
+ end
67
+
68
+ def execute(pattern:, file_pattern: nil, ignore_case: false, context_lines: 0, line_range_start: 1, line_range_end: nil, **params)
69
+ if params.any?
70
+ return "The following params were passed but are not allowed: #{params.keys.join(",")}"
71
+ end
72
+
73
+ Dir.chdir(workspace_dir) do
74
+
75
+ cmd = ["git", "grep", "-n", "--extended-regexp"] # -n shows line numbers
76
+
77
+ cmd << "-i" if ignore_case
78
+ cmd << "-C" << context_lines.to_s if context_lines > 0
79
+
80
+ cmd << "-e" << pattern
81
+
82
+ if file_pattern
83
+ cmd << "--" << file_pattern
84
+ end
85
+
86
+
87
+ stdout, stderr, status = Open3.capture3(*cmd)
88
+
89
+ if status.success?
90
+ lines = stdout.force_encoding("UTF-8").split("\n")
91
+
92
+ # Apply line range (convert to 0-indexed)
93
+ start_idx = [line_range_start - 1, 0].max
94
+ end_idx = line_range_end ? line_range_end - 1 : -1
95
+ lines = lines[start_idx..end_idx] || []
96
+
97
+ lines = lines.map { |l| l[0..100]}
98
+
99
+ total_lines = lines.length
100
+ max_lines = 300
101
+
102
+ if total_lines > max_lines
103
+ limited_content = lines[0...max_lines].join("\n")
104
+ return "Returning #{max_lines} lines out of #{total_lines} total lines:\n\n#{limited_content}"
105
+ else
106
+ return lines.join("\n")
107
+ end
108
+ elsif status.exitstatus == 1
109
+ # Exit status 1 means no matches found
110
+ return "No matches found."
111
+ else
112
+ # Other error
113
+ return "Error: #{stderr}"
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module AgentC
6
+ module Tools
7
+ module Paths
8
+ module_function
9
+
10
+ def relative_to_dir(dir, path)
11
+ if path.start_with?(dir)
12
+ path
13
+ else
14
+ File.join(dir, path)
15
+ end
16
+ end
17
+
18
+ def allowed?(dir, path)
19
+ child?(dir, path)
20
+ end
21
+
22
+ def child?(parent_dir, child_path)
23
+
24
+ unless child_path.start_with?("/")
25
+ child_path = File.join(parent_dir, child_path)
26
+ end
27
+
28
+ child = Pathname.new(child_path).expand_path
29
+ parent = Pathname.new(parent_dir).expand_path
30
+
31
+ relative = child.relative_path_from(parent)
32
+ !relative.to_s.start_with?('..')
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ raise "here" if defined?(AgentC::Tools::ReadFile)
4
+
5
+ module AgentC
6
+ module Tools
7
+ class ReadFile < RubyLLM::Tool
8
+ description "Reads the contents of a file"
9
+
10
+ params do
11
+ string(
12
+ :path,
13
+ description: <<~TXT
14
+ Path to file. Must be a child of current directory.
15
+
16
+ Only returns 300 lines at a time. Specify line range if possible
17
+ to avoid truncated responses.
18
+ TXT
19
+ )
20
+
21
+ number(
22
+ :line_range_start,
23
+ description: "Read lines on or after this line number.",
24
+ required: false
25
+ )
26
+
27
+ number(
28
+ :line_range_end,
29
+ description: "Read lines on or before this line number.",
30
+ required: false
31
+ )
32
+ end
33
+
34
+ attr_reader :workspace_dir
35
+ def initialize(workspace_dir: nil, **)
36
+ raise ArgumentError, "workspace_dir is required" unless workspace_dir
37
+ @workspace_dir = workspace_dir
38
+ end
39
+
40
+
41
+ def execute(path:, line_range_start: 0, line_range_end: nil, **params)
42
+ if params.any?
43
+ return "The following params were passed but are not allowed: #{params.keys.join(",")}"
44
+ end
45
+
46
+ unless Paths.allowed?(workspace_dir, path)
47
+ return "Path: #{path} not acceptable. Must be a child of directory: #{workspace_dir}."
48
+ end
49
+
50
+ workspace_path = Paths.relative_to_dir(workspace_dir, path)
51
+
52
+ unless File.exist?(workspace_path)
53
+ return "File not found"
54
+ end
55
+
56
+ all_lines = File.read(workspace_path).split("\n")
57
+ lines = all_lines[line_range_start..line_range_end]
58
+
59
+ total_lines = lines.length
60
+ max_lines = 300
61
+
62
+ # Determine the actual line range being returned (1-indexed for display)
63
+ actual_start = line_range_start + 1
64
+ actual_end = line_range_end ? [line_range_end + 1, all_lines.length].min : all_lines.length
65
+
66
+ if total_lines > max_lines
67
+ limited_content = format_with_line_numbers(lines[0...max_lines], actual_start)
68
+ limited_end = actual_start + max_lines - 1
69
+ "Returning lines #{actual_start}-#{limited_end}, out of #{total_lines} total lines:\n\n#{limited_content}"
70
+ elsif line_range_start > 0 || line_range_end
71
+ # Line range was specified
72
+ formatted_content = format_with_line_numbers(lines, actual_start)
73
+ "Returning lines #{actual_start}-#{actual_end}, out of #{all_lines.length} total lines:\n\n#{formatted_content}"
74
+ else
75
+ format_with_line_numbers(lines, 1)
76
+ end
77
+ rescue => e
78
+ "File read error: #{e.class}:#{e.message}"
79
+ end
80
+
81
+ private
82
+
83
+ # Format lines with line numbers
84
+ def format_with_line_numbers(lines, starting_line_num)
85
+ max_line_num = starting_line_num + lines.length - 1
86
+ width = max_line_num.to_s.length
87
+ lines.map.with_index do |line, idx|
88
+ line_num = starting_line_num + idx
89
+ "%#{width}d: %s" % [line_num, line]
90
+ end.join("\n")
91
+ end
92
+ end
93
+ end
94
+ end