llm_gateway 0.5.0 → 0.7.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 +38 -0
- data/README.md +350 -43
- data/docs/migration_guide_0.6.0.md +386 -0
- data/docs/migration_guide_0.7.0.md +193 -0
- data/lib/llm_gateway/adapters/adapter.rb +8 -11
- data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +24 -0
- data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +61 -11
- data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/groq/option_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/input_message_sanitizer.rb +98 -7
- data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +132 -39
- data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +40 -16
- data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +47 -31
- data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +173 -24
- data/lib/llm_gateway/adapters/stream_mapper.rb +9 -2
- data/lib/llm_gateway/adapters/structs.rb +140 -55
- data/lib/llm_gateway/agents/event.rb +105 -0
- data/lib/llm_gateway/agents/file_session_manager.rb +100 -0
- data/lib/llm_gateway/agents/harness.rb +176 -0
- data/lib/llm_gateway/agents/in_memory_session_manager.rb +222 -0
- data/lib/llm_gateway/agents/tools/bash_tool.rb +132 -0
- data/lib/llm_gateway/agents/tools/edit_tool.rb +215 -0
- data/lib/llm_gateway/agents/tools/read_tool.rb +143 -0
- data/lib/llm_gateway/agents/tools/tool_utils.rb +164 -0
- data/lib/llm_gateway/agents/tools/write_tool.rb +34 -0
- data/lib/llm_gateway/base_client.rb +5 -7
- data/lib/llm_gateway/clients/anthropic.rb +10 -9
- data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +2 -2
- data/lib/llm_gateway/clients/groq.rb +8 -6
- data/lib/llm_gateway/clients/openai.rb +22 -20
- data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +4 -4
- data/lib/llm_gateway/prompt.rb +107 -52
- data/lib/llm_gateway/utils.rb +116 -13
- data/lib/llm_gateway/version.rb +1 -1
- data/lib/llm_gateway.rb +7 -21
- metadata +13 -2
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module LlmGateway
|
|
7
|
+
module Agents
|
|
8
|
+
class InMemorySessionManager
|
|
9
|
+
MESSAGE_QUEUED = :queued
|
|
10
|
+
MESSAGE_STARTED = :started
|
|
11
|
+
QUEUES = [ :steer, :follow_up, :next_turn ].freeze
|
|
12
|
+
DRAIN_MODES = [ :one_at_a_time, :all ].freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :session_id, :session_start
|
|
15
|
+
|
|
16
|
+
def initialize(session_id = nil)
|
|
17
|
+
@state = :idle
|
|
18
|
+
@session_id = session_id
|
|
19
|
+
@message_queues = Hash.new { |hash, key| hash[key] = [] }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def busy!
|
|
23
|
+
@state = :busy
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def idle!
|
|
27
|
+
@state = :idle
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def drain_message_queue(queue = :next_turn, mode: :all)
|
|
31
|
+
messages = queued_messages(queue, mode)
|
|
32
|
+
messages.each { |message| push_message(message) }
|
|
33
|
+
messages
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def queued_messages?(queue = :next_turn)
|
|
37
|
+
@message_queues[validate_queue!(queue)].any?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def push_message_to_queue(message, queue = :next_turn)
|
|
41
|
+
@message_queues[validate_queue!(queue)] << message
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def busy?
|
|
45
|
+
@state == :busy
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def validate_queue!(queue)
|
|
49
|
+
queue = queue.to_sym
|
|
50
|
+
raise ArgumentError, "Invalid queue mode: #{queue}" unless QUEUES.include?(queue)
|
|
51
|
+
|
|
52
|
+
queue
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def validate_drain_mode!(mode)
|
|
56
|
+
mode = mode.to_sym
|
|
57
|
+
raise ArgumentError, "Invalid queue drain mode: #{mode}" unless DRAIN_MODES.include?(mode)
|
|
58
|
+
|
|
59
|
+
mode
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def start_or_enqueue_user_message(payload, queue: :next_turn)
|
|
63
|
+
if busy?
|
|
64
|
+
push_message_to_queue(payload, queue)
|
|
65
|
+
MESSAGE_QUEUED
|
|
66
|
+
else
|
|
67
|
+
yield if block_given?
|
|
68
|
+
push_message(payload)
|
|
69
|
+
busy!
|
|
70
|
+
MESSAGE_STARTED
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def push_message(payload)
|
|
75
|
+
payload = payload.deep_symbolize_keys
|
|
76
|
+
|
|
77
|
+
push_entry(
|
|
78
|
+
type: "message",
|
|
79
|
+
usage: message_usage(payload),
|
|
80
|
+
data: payload,
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def push_entry(entry)
|
|
85
|
+
id = SecureRandom.uuid
|
|
86
|
+
new_entry = {
|
|
87
|
+
id: id,
|
|
88
|
+
parent_id: parent_id_for_new_entry,
|
|
89
|
+
timestamp: Time.now.iso8601,
|
|
90
|
+
**entry
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
persist_entry(new_entry)
|
|
94
|
+
new_entry
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def active_messages
|
|
98
|
+
active_message_events.map { |event| event[:data] }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def last_message_id
|
|
102
|
+
message_events.last&.dig(:id)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def last_model_used
|
|
106
|
+
events.reverse.find { |event| event[:type] == "model_change" }&.dig(:model_id)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def last_reasoning_level_used
|
|
110
|
+
events.reverse.find { |event| event[:type] == "reasoning_change" }&.dig(:reasoning)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def events_until(event_id)
|
|
114
|
+
index = events.index { |event| event[:id] == event_id }
|
|
115
|
+
raise ArgumentError, "Event not found in session: #{event_id}" unless index
|
|
116
|
+
|
|
117
|
+
events[0..index]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def events
|
|
121
|
+
@events ||= [ new_session_event ]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def build_model_input_messages
|
|
125
|
+
return active_messages unless last_compaction_entry
|
|
126
|
+
|
|
127
|
+
[ last_compaction_entry[:data], *active_messages ]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def total_tokens
|
|
131
|
+
entry = active_message_events.reverse.find { |event| event.dig(:usage, :total_tokens) }
|
|
132
|
+
entry&.dig(:usage, :total_tokens) || 0
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def last_assistant_message_at
|
|
136
|
+
entry = active_message_events.reverse.find { |event| event.dig(:data, :role) == "assistant" }
|
|
137
|
+
Time.parse(entry[:timestamp]) if entry
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def compaction(adapter)
|
|
141
|
+
response = adapter.stream(
|
|
142
|
+
active_messages,
|
|
143
|
+
system: "Summarize the conversation so far for future context.",
|
|
144
|
+
tools: []
|
|
145
|
+
)
|
|
146
|
+
message = response.to_h
|
|
147
|
+
|
|
148
|
+
push_entry(
|
|
149
|
+
type: "compaction",
|
|
150
|
+
usage: message_usage(message),
|
|
151
|
+
data: message
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def queued_messages(queue, mode)
|
|
158
|
+
queue = validate_queue!(queue)
|
|
159
|
+
case validate_drain_mode!(mode)
|
|
160
|
+
when :one_at_a_time
|
|
161
|
+
message = @message_queues[queue].shift
|
|
162
|
+
message ? [ message ] : []
|
|
163
|
+
when :all
|
|
164
|
+
@message_queues[queue].shift(@message_queues[queue].length)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def parent_id_for_new_entry
|
|
169
|
+
events.length.positive? ? events.last[:id] : nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def message_events
|
|
173
|
+
events.select { |event| event[:type] == "message" }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def active_message_events
|
|
177
|
+
compaction_event = last_compaction_entry
|
|
178
|
+
return message_events unless compaction_event
|
|
179
|
+
|
|
180
|
+
compaction_index = events.index(compaction_event)
|
|
181
|
+
events[(compaction_index + 1)..].select { |event| event[:type] == "message" }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def last_compaction_entry
|
|
185
|
+
events.reverse.find { |event| event[:type] == "compaction" }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def message_usage(message)
|
|
189
|
+
usage = message[:usage] || message["usage"]
|
|
190
|
+
return {} unless usage
|
|
191
|
+
|
|
192
|
+
usage.transform_keys(&:to_sym)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def persist_entry(entry)
|
|
196
|
+
attributes = {
|
|
197
|
+
session_id: @session_id,
|
|
198
|
+
position: next_position,
|
|
199
|
+
id: entry[:id],
|
|
200
|
+
parent_id: entry[:parent_id],
|
|
201
|
+
timestamp: entry[:timestamp],
|
|
202
|
+
type: entry[:type],
|
|
203
|
+
usage: entry[:usage],
|
|
204
|
+
data: entry[:data]
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
events << entry
|
|
208
|
+
attributes
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def next_position
|
|
212
|
+
events.length
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def new_session_event
|
|
216
|
+
@session_id ||= SecureRandom.uuid
|
|
217
|
+
@session_start = Time.now.strftime("%Y%m%d_%H%M%S")
|
|
218
|
+
{ type: "session", id: session_id, timestamp: session_start }
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
require "tmpdir"
|
|
3
|
+
require_relative "tool_utils"
|
|
4
|
+
|
|
5
|
+
class BashTool < LlmGateway::Tool
|
|
6
|
+
# Pi adaptation notes:
|
|
7
|
+
# - Keep timeout schema as integer: gruv treats integer and number schemas equivalently for seconds.
|
|
8
|
+
# - Do not add pi's pluggable operations, shell/env hooks, command prefix, AbortSignal handling, partial updates, or UI render details: those are runtime/UI extension concerns outside this tool contract.
|
|
9
|
+
name "bash"
|
|
10
|
+
description "Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last #{ToolUtils::DEFAULT_MAX_LINES} lines or #{ToolUtils::DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds."
|
|
11
|
+
input_schema({
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
command: { type: "string", description: "Bash command to execute" },
|
|
15
|
+
timeout: { type: "integer", description: "Timeout in seconds (optional, no default timeout)" }
|
|
16
|
+
},
|
|
17
|
+
required: [ "command" ]
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
def execute(input)
|
|
21
|
+
command = input[:command]
|
|
22
|
+
timeout = input[:timeout]
|
|
23
|
+
|
|
24
|
+
result = run_command(command, timeout)
|
|
25
|
+
out = format_output(result[:output], empty_text: result[:timed_out] ? "" : "(no output)")
|
|
26
|
+
|
|
27
|
+
if result[:timed_out]
|
|
28
|
+
return append_status(out, "Command timed out after #{timeout} seconds")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
if result[:exit_status] && result[:exit_status] != 0
|
|
32
|
+
return append_status(out, "Command exited with code #{result[:exit_status]}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
out
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
e.message
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def run_command(command, timeout)
|
|
43
|
+
output = +""
|
|
44
|
+
timed_out = false
|
|
45
|
+
read_io, write_io = IO.pipe
|
|
46
|
+
pid = Process.spawn(command, chdir: Dir.pwd, in: File::NULL, out: write_io, err: write_io, pgroup: true)
|
|
47
|
+
write_io.close
|
|
48
|
+
|
|
49
|
+
deadline = timeout && timeout.positive? ? Time.now + timeout : nil
|
|
50
|
+
|
|
51
|
+
loop do
|
|
52
|
+
remaining = deadline ? deadline - Time.now : nil
|
|
53
|
+
if remaining && remaining <= 0
|
|
54
|
+
timed_out = true
|
|
55
|
+
terminate_process_group(pid)
|
|
56
|
+
break
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
ready = IO.select([ read_io ], nil, nil, remaining)
|
|
60
|
+
unless ready
|
|
61
|
+
timed_out = true
|
|
62
|
+
terminate_process_group(pid)
|
|
63
|
+
break
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
begin
|
|
67
|
+
output << read_io.readpartial(16 * 1024)
|
|
68
|
+
rescue EOFError
|
|
69
|
+
break
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
_, status = Process.wait2(pid)
|
|
74
|
+
drain_available_output(read_io, output)
|
|
75
|
+
read_io.close
|
|
76
|
+
|
|
77
|
+
{ output: output, exit_status: status.exitstatus, timed_out: timed_out }
|
|
78
|
+
ensure
|
|
79
|
+
write_io.close if write_io && !write_io.closed?
|
|
80
|
+
read_io.close if read_io && !read_io.closed?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def drain_available_output(read_io, output)
|
|
84
|
+
loop do
|
|
85
|
+
ready = IO.select([ read_io ], nil, nil, 0.1)
|
|
86
|
+
break unless ready
|
|
87
|
+
|
|
88
|
+
begin
|
|
89
|
+
output << read_io.readpartial(16 * 1024)
|
|
90
|
+
rescue EOFError
|
|
91
|
+
break
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def terminate_process_group(pid)
|
|
97
|
+
Process.kill("TERM", -pid)
|
|
98
|
+
sleep 0.1
|
|
99
|
+
Process.kill("KILL", -pid)
|
|
100
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def format_output(output, empty_text: "(no output)")
|
|
105
|
+
truncation = ToolUtils.truncate_tail(output)
|
|
106
|
+
out = truncation[:content]
|
|
107
|
+
out = empty_text if out.empty?
|
|
108
|
+
|
|
109
|
+
return out unless truncation[:truncated]
|
|
110
|
+
|
|
111
|
+
temp_path = File.join(Dir.tmpdir, "pi-bash-#{SecureRandom.hex(8)}.log")
|
|
112
|
+
File.write(temp_path, output)
|
|
113
|
+
|
|
114
|
+
start_line = truncation[:total_lines] - truncation[:output_lines] + 1
|
|
115
|
+
end_line = truncation[:total_lines]
|
|
116
|
+
|
|
117
|
+
notice = if truncation[:last_line_partial]
|
|
118
|
+
last_line = output.split("\n", -1).last
|
|
119
|
+
"[Showing last #{ToolUtils.format_size(truncation[:output_bytes])} of line #{end_line} (line is #{ToolUtils.format_size(last_line.bytesize)}). Full output: #{temp_path}]"
|
|
120
|
+
elsif truncation[:truncated_by] == "lines"
|
|
121
|
+
"[Showing lines #{start_line}-#{end_line} of #{truncation[:total_lines]}. Full output: #{temp_path}]"
|
|
122
|
+
else
|
|
123
|
+
"[Showing lines #{start_line}-#{end_line} of #{truncation[:total_lines]} (#{ToolUtils.format_size(ToolUtils::DEFAULT_MAX_BYTES)} limit). Full output: #{temp_path}]"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
"#{out}\n\n#{notice}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def append_status(text, status)
|
|
130
|
+
text.empty? ? status : "#{text}\n\n#{status}"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "tool_utils"
|
|
3
|
+
|
|
4
|
+
class EditTool < LlmGateway::Tool
|
|
5
|
+
# Pi adaptation notes:
|
|
6
|
+
# - Legacy single-edit input: pi accepts oldText/newText and converts to edits,
|
|
7
|
+
# but this tool intentionally exposes/supports only the edits array to keep the LLM contract unambiguous.
|
|
8
|
+
# - Do not add pi's diff/patch details, preview rendering, pluggable operations, or AbortSignal handling: those are UI/runtime extension concerns outside this tool contract.
|
|
9
|
+
name "edit"
|
|
10
|
+
|
|
11
|
+
class EditError < StandardError; end
|
|
12
|
+
description "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits."
|
|
13
|
+
input_schema({
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
path: { type: "string", description: "Path to the file to edit (relative or absolute)" },
|
|
17
|
+
edits: {
|
|
18
|
+
type: "array",
|
|
19
|
+
description: "One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits.",
|
|
20
|
+
items: {
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: {
|
|
23
|
+
oldText: { type: "string", description: "Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call." },
|
|
24
|
+
newText: { type: "string", description: "Replacement text for this targeted edit." }
|
|
25
|
+
},
|
|
26
|
+
required: [ "oldText", "newText" ]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
required: [ "path", "edits" ]
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
def execute(input)
|
|
34
|
+
path = input[:path]
|
|
35
|
+
edits = prepare_edits(input[:edits])
|
|
36
|
+
|
|
37
|
+
return "Edit tool input is invalid. edits must contain at least one replacement." if !edits.is_a?(Array) || edits.empty?
|
|
38
|
+
|
|
39
|
+
absolute_path = ToolUtils.resolve_to_cwd(path)
|
|
40
|
+
|
|
41
|
+
ToolUtils.with_file_mutation_lock(absolute_path) do
|
|
42
|
+
begin
|
|
43
|
+
File.open(absolute_path, File::RDWR) { }
|
|
44
|
+
rescue SystemCallError => e
|
|
45
|
+
return "Could not edit file: #{path}. Error code: #{e.class.name.split("::").last}."
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
raw_content = File.binread(absolute_path)
|
|
49
|
+
|
|
50
|
+
bom = raw_content.start_with?("\xEF\xBB\xBF".b) ? "\xEF\xBB\xBF".b : "".b
|
|
51
|
+
content_without_bom = bom.empty? ? raw_content : raw_content.byteslice(3..)
|
|
52
|
+
content_utf8 = content_without_bom.force_encoding("UTF-8")
|
|
53
|
+
|
|
54
|
+
original_ending = detect_line_ending(content_utf8)
|
|
55
|
+
normalized_content = normalize_to_lf(content_utf8)
|
|
56
|
+
base_content, new_content = apply_edits_to_normalized_content(normalized_content, edits, path)
|
|
57
|
+
|
|
58
|
+
restored = restore_line_endings(new_content, original_ending)
|
|
59
|
+
final_bytes = bom + restored.encode("UTF-8").b
|
|
60
|
+
|
|
61
|
+
File.binwrite(absolute_path, final_bytes)
|
|
62
|
+
"Successfully replaced #{edits.length} block(s) in #{path}."
|
|
63
|
+
end
|
|
64
|
+
rescue EditError => e
|
|
65
|
+
e.message
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
"Error editing file: #{e.message}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def prepare_edits(edits)
|
|
73
|
+
return JSON.parse(edits, symbolize_names: true) if edits.is_a?(String)
|
|
74
|
+
|
|
75
|
+
edits
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def detect_line_ending(content)
|
|
79
|
+
crlf_index = content.index("\r\n")
|
|
80
|
+
lf_index = content.index("\n")
|
|
81
|
+
return "\n" unless lf_index
|
|
82
|
+
return "\n" unless crlf_index
|
|
83
|
+
|
|
84
|
+
crlf_index < lf_index ? "\r\n" : "\n"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def normalize_to_lf(text)
|
|
88
|
+
text.gsub("\r\n", "\n").gsub("\r", "\n")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def restore_line_endings(text, ending)
|
|
92
|
+
ending == "\r\n" ? text.gsub("\n", "\r\n") : text
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def normalize_for_fuzzy_match(text)
|
|
96
|
+
text
|
|
97
|
+
.unicode_normalize(:nfkc)
|
|
98
|
+
.split("\n", -1)
|
|
99
|
+
.map(&:rstrip)
|
|
100
|
+
.join("\n")
|
|
101
|
+
.gsub(/[\u2018\u2019\u201A\u201B]/, "'")
|
|
102
|
+
.gsub(/[\u201C\u201D\u201E\u201F]/, '"')
|
|
103
|
+
.gsub(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/, "-")
|
|
104
|
+
.gsub(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/, " ")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def fuzzy_find_text(content, old_text)
|
|
108
|
+
exact_index = content.index(old_text)
|
|
109
|
+
if exact_index
|
|
110
|
+
return {
|
|
111
|
+
found: true,
|
|
112
|
+
index: exact_index,
|
|
113
|
+
match_length: old_text.length,
|
|
114
|
+
used_fuzzy_match: false,
|
|
115
|
+
content_for_replacement: content
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
fuzzy_content = normalize_for_fuzzy_match(content)
|
|
120
|
+
fuzzy_old_text = normalize_for_fuzzy_match(old_text)
|
|
121
|
+
fuzzy_index = fuzzy_content.index(fuzzy_old_text)
|
|
122
|
+
|
|
123
|
+
return { found: false, index: -1, match_length: 0, used_fuzzy_match: false, content_for_replacement: content } unless fuzzy_index
|
|
124
|
+
|
|
125
|
+
{
|
|
126
|
+
found: true,
|
|
127
|
+
index: fuzzy_index,
|
|
128
|
+
match_length: fuzzy_old_text.length,
|
|
129
|
+
used_fuzzy_match: true,
|
|
130
|
+
content_for_replacement: fuzzy_content
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def count_occurrences(content, old_text)
|
|
135
|
+
fuzzy_content = normalize_for_fuzzy_match(content)
|
|
136
|
+
fuzzy_old_text = normalize_for_fuzzy_match(old_text)
|
|
137
|
+
fuzzy_content.split(fuzzy_old_text, -1).length - 1
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def apply_edits_to_normalized_content(normalized_content, edits, path)
|
|
141
|
+
normalized_edits = edits.map do |edit|
|
|
142
|
+
{
|
|
143
|
+
oldText: normalize_to_lf(edit[:oldText]),
|
|
144
|
+
newText: normalize_to_lf(edit[:newText])
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
normalized_edits.each_with_index do |edit, index|
|
|
149
|
+
raise EditError, empty_old_text_error(path, index, normalized_edits.length) if edit[:oldText].empty?
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
initial_matches = normalized_edits.map { |edit| fuzzy_find_text(normalized_content, edit[:oldText]) }
|
|
153
|
+
base_content = initial_matches.any? { |match| match[:used_fuzzy_match] } ? normalize_for_fuzzy_match(normalized_content) : normalized_content
|
|
154
|
+
|
|
155
|
+
matched_edits = []
|
|
156
|
+
normalized_edits.each_with_index do |edit, index|
|
|
157
|
+
match = fuzzy_find_text(base_content, edit[:oldText])
|
|
158
|
+
raise EditError, not_found_error(path, index, normalized_edits.length) unless match[:found]
|
|
159
|
+
|
|
160
|
+
occurrences = count_occurrences(base_content, edit[:oldText])
|
|
161
|
+
raise EditError, duplicate_error(path, index, normalized_edits.length, occurrences) if occurrences > 1
|
|
162
|
+
|
|
163
|
+
matched_edits << {
|
|
164
|
+
edit_index: index,
|
|
165
|
+
match_index: match[:index],
|
|
166
|
+
match_length: match[:match_length],
|
|
167
|
+
new_text: edit[:newText]
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
matched_edits.sort_by! { |edit| edit[:match_index] }
|
|
172
|
+
matched_edits.each_cons(2) do |previous, current|
|
|
173
|
+
next unless previous[:match_index] + previous[:match_length] > current[:match_index]
|
|
174
|
+
|
|
175
|
+
raise EditError, "edits[#{previous[:edit_index]}] and edits[#{current[:edit_index]}] overlap in #{path}. Merge them into one edit or target disjoint regions."
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
new_content = base_content.dup
|
|
179
|
+
matched_edits.reverse_each do |edit|
|
|
180
|
+
new_content = new_content[0...edit[:match_index]] + edit[:new_text] + new_content[(edit[:match_index] + edit[:match_length])..]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
raise EditError, no_change_error(path, normalized_edits.length) if base_content == new_content
|
|
184
|
+
|
|
185
|
+
[ base_content, new_content ]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def not_found_error(path, edit_index, total_edits)
|
|
189
|
+
return "Could not find the exact text in #{path}. The old text must match exactly including all whitespace and newlines." if total_edits == 1
|
|
190
|
+
|
|
191
|
+
"Could not find edits[#{edit_index}] in #{path}. The oldText must match exactly including all whitespace and newlines."
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def duplicate_error(path, edit_index, total_edits, occurrences)
|
|
195
|
+
if total_edits == 1
|
|
196
|
+
return "Found #{occurrences} occurrences of the text in #{path}. The text must be unique. Please provide more context to make it unique."
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
"Found #{occurrences} occurrences of edits[#{edit_index}] in #{path}. Each oldText must be unique. Please provide more context to make it unique."
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def empty_old_text_error(path, edit_index, total_edits)
|
|
203
|
+
return "oldText must not be empty in #{path}." if total_edits == 1
|
|
204
|
+
|
|
205
|
+
"edits[#{edit_index}].oldText must not be empty in #{path}."
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def no_change_error(path, total_edits)
|
|
209
|
+
if total_edits == 1
|
|
210
|
+
return "No changes made to #{path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected."
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
"No changes made to #{path}. The replacements produced identical content."
|
|
214
|
+
end
|
|
215
|
+
end
|