cowrite 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 92022a9db5707f4dae9c95807e6eb39b813056ea3b39c252d3f65aa67d62acf5
4
+ data.tar.gz: 5b18e63cc172da86b5dba8d098510a00bb7c888fd2560f3f6f38a007f77c2aa5
5
+ SHA512:
6
+ metadata.gz: 8b7560a50a193d66dd3208786deeb95638a67517c9cd630377ac8b3653f262f112f3b93e8ffcf820ef1b604661725bf2f0208c3891daa16abe2078f9f3374f37
7
+ data.tar.gz: dcb4ee5bbb99036cdd506425102c905d7dc22ecedeed3125b02e08d700b7d35dc766247ac4827d30b01372bde42afec7a7004c76ebf44682e5d509c6f30eeb1e
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (C) 2013 Michael Grosser <michael@grosser.it>
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/bin/cowrite ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # enable local usage from cloned repo
5
+ root = File.expand_path('..', __dir__)
6
+ $LOAD_PATH << "#{root}/lib" if File.exist?("#{root}/Gemfile")
7
+
8
+ require "cowrite"
9
+ require "cowrite/cli"
10
+
11
+ Cowrite::CLI.new.run(ARGV)
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require "parallel"
5
+ require "tempfile"
6
+
7
+ class Cowrite
8
+ class CLI
9
+ QUESTION_COLOR = :blue
10
+
11
+ def initialize
12
+ super
13
+
14
+ # get config first so we fail fast
15
+ api_key = ENV.fetch("COWRITE_API_KEY")
16
+ url = ENV.fetch("COWRITE_URL", "https://api.openai.com")
17
+ model = ENV.fetch("MODEL", "gpt-4o")
18
+ @cowrite = Cowrite.new(url:, api_key:, model:)
19
+ end
20
+
21
+ def run(argv)
22
+ abort "Use only first argument for prompt" if argv.size != 1 # TODO: remove
23
+ prompt = ARGV[0]
24
+
25
+ files = find_files prompt
26
+
27
+ # prompting on main thread so we can go 1-by-1
28
+ finish = lambda do |file, i, diff|
29
+ # ask user if diff is fine (TODO: add a "no" option and re-prompt somehow)
30
+ prompt "Diff for #{file}:\n#{color_diff(diff)}Apply diff to #{file}?", ["yes"]
31
+
32
+ with_content_in_file("#{diff.strip}\n") do |path|
33
+ # apply diff (force sus changes, do not make backups)
34
+ cmd = "patch -f --posix #{file} < #{path}"
35
+ out = `#{cmd}`
36
+ return if $?.success?
37
+
38
+ # give the user a chance to copy the tempfile or modify it
39
+ warn "Patch failed:\n#{cmd}\n#{out}"
40
+ prompt "Continue ?", ["yes"] unless i + 1 == files.size
41
+ end
42
+ end
43
+
44
+ # TODO: --parallel instead of env
45
+ # produce diffs in parallel since it is slow
46
+ Parallel.each files, finish:, threads: Integer(ENV["PARALLEL"] || "10"), progress: true do |file|
47
+ @cowrite.diff file, prompt
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def with_content_in_file(content)
54
+ Tempfile.create("cowrite-diff") do |f|
55
+ f.write content
56
+ f.close
57
+ yield f.path
58
+ end
59
+ end
60
+
61
+ def find_files(prompt)
62
+ files = @cowrite.files prompt
63
+ files.each_with_index { |f, i| warn "#{i}: #{f}" } # needs to match choose_files logic
64
+ answer = prompt("Accept #{files.size} files to iterate ?", ["yes", "choose"])
65
+ answer == "choose" ? choose_files(files) : files
66
+ end
67
+
68
+ # let user select for index of given files or select whatever files they want via path
69
+ def choose_files(files)
70
+ answer = prompt_freeform("Enter index or path of files command separated")
71
+ chosen = answer.split(/\s?,\s?/)
72
+ abort "No files selected" if chosen.empty?
73
+ chosen =
74
+ files.each_with_index.filter_map { |f, i| f if chosen.include?(i.to_s) } + # index
75
+ chosen.grep_v(/^\d+$/) # path
76
+ missing = chosen.reject { |f| File.exist?(f) }
77
+ abort "Files #{missing} do not exist" if missing.any?
78
+ chosen
79
+ end
80
+
81
+ # prompt user with questions and answers until they pick one of them
82
+ # - supports replying with the first letter of the answer
83
+ # - supports enter for yes
84
+ def prompt(question, answers)
85
+ colored_answers = answers.map { |a| color(:underline, a[0]) + a[1...] }.join("/")
86
+ loop do
87
+ read = prompt_freeform "#{color_last_line(QUESTION_COLOR, question)} [#{colored_answers}]", color: :none
88
+ read = "yes" if read == "" && answers.include?("yes")
89
+ return read if answers.include?(read)
90
+ if (ai = answers.map { |a| a[0] }.index(read))
91
+ return answers[ai]
92
+ end
93
+ end
94
+ end
95
+
96
+ def prompt_freeform(question, color: QUESTION_COLOR)
97
+ warn color(color, question)
98
+ $stdin.gets.strip
99
+ end
100
+
101
+ def color_last_line(color, text)
102
+ modify_lines(text) { |l, i, size| i + 1 == size ? color(color, l) : l }
103
+ end
104
+
105
+ # color lines with +/- but not the header with ---/+++
106
+ def color_diff(diff)
107
+ modify_lines(diff) do |l, _, _|
108
+ if l =~ /^-[^-]/
109
+ color(:bg_light_green, l)
110
+ elsif l =~ /^\+[^+]/
111
+ color(:bg_light_red, l)
112
+ else
113
+ l
114
+ end
115
+ end
116
+ end
117
+
118
+ def modify_lines(text)
119
+ lines = text.split("\n", -1)
120
+ size = lines.size
121
+ lines = lines.each_with_index.map { |l, i| yield l, i, size }
122
+ lines.join("\n")
123
+ end
124
+
125
+ def color(color, text)
126
+ return text if color == :none
127
+ code =
128
+ case color
129
+ when :underline then 4
130
+ when :blue then 34
131
+ when :bg_light_red then 101
132
+ when :bg_light_green then 102
133
+ else raise ArgumentError
134
+ end
135
+ "\e[#{code}m#{text}\e[0m"
136
+ end
137
+
138
+ def remove_shell_colors(string)
139
+ string.gsub(/\e\[(\d+)(;\d+)*m/, "")
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ class Cowrite
3
+ VERSION = "0.1.0"
4
+ end
data/lib/cowrite.rb ADDED
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
+
7
+ class Cowrite
8
+ def initialize(url:, api_key:, model:)
9
+ @url = url
10
+ @api_key = api_key
11
+ @model = model
12
+ end
13
+
14
+ def files(prompt)
15
+ # TODO: ask llm which folders to search when >1k files
16
+ files = `git ls-files`
17
+ abort files unless $?.success?
18
+
19
+ prompt = <<~MSG
20
+ Your task is to find a subset of files from a given list of file names.
21
+ Only reply with the subset of files, newline separated, nothing else.
22
+
23
+ Given this list of files: #{files.split("\n").inspect}
24
+
25
+ Which subset of files would be useful for this LLM prompt:
26
+ ```
27
+ #{prompt}
28
+ ```
29
+ MSG
30
+ puts "prompt:#{prompt}" if ENV["DEBUG"]
31
+ answer = send_to_openai(prompt)
32
+ puts "answer:\n#{answer}" if ENV["DEBUG"]
33
+ without_quotes(answer).split("\n").map(&:strip) # llms like to add extra spaces
34
+ end
35
+
36
+ def diff(file, prompt)
37
+ # - tried "patch format" but that is often invalid
38
+ # - tied "full fixed content" but that is always missing the fix
39
+ # - need "ONLY" or it adds comments
40
+ prompt = <<~MSG
41
+ Solve this prompt:
42
+ ```
43
+ #{prompt}
44
+ ```
45
+
46
+ By changing the content of the file #{file}:
47
+ ```
48
+ #{File.read file}
49
+ ```
50
+
51
+ Reply with ONLY the change in diff format.
52
+ MSG
53
+ puts "prompt:#{prompt}" if ENV["DEBUG"]
54
+ answer = send_to_openai(prompt)
55
+ puts "answer:\n#{answer}" if ENV["DEBUG"]
56
+ without_quotes(answer)
57
+ end
58
+
59
+ private
60
+
61
+ # remove ```foo<content>``` wrapping
62
+ def without_quotes(answer)
63
+ answer.strip.sub(/\A```\S*\n(.*)```\z/m, "\\1")
64
+ end
65
+
66
+ # def lines_from_file(file_path, line_number)
67
+ # start_line = [line_number - @context, 1].max
68
+ # end_line = line_number + @context
69
+ #
70
+ # lines = File.read(file_path).split("\n", -1)
71
+ # context = lines.each_with_index.map { |l, i| "line #{(i + 1).to_s.rjust(5, " ")}:#{l}" }
72
+ # [lines[line_number - 1], context[start_line - 1..end_line - 1]]
73
+ # end
74
+ #
75
+ # def replace_line_in_file(file_path, line_number, new_line)
76
+ # lines = File.read(file_path).split("\n", -1)
77
+ # lines[line_number - 1] = new_line
78
+ # File.write(file_path, lines.join("\n"))
79
+ # end
80
+ #
81
+ # def append_line_to_file(path, answer)
82
+ # File.open(path, "a") do |f|
83
+ # f.puts(answer)
84
+ # end
85
+ # end
86
+ #
87
+ # def remove_line_in_file(path, line_number)
88
+ # lines = File.read(path).split("\n", -1)
89
+ # lines.delete_at(line_number - 1)
90
+ # File.write(path, lines.join("\n"))
91
+ # end
92
+
93
+ def send_to_openai(prompt)
94
+ uri = URI.parse("#{@url}/v1/chat/completions")
95
+ request = Net::HTTP::Post.new(uri)
96
+ request.content_type = "application/json"
97
+ request["Authorization"] = "Bearer #{@api_key}"
98
+
99
+ request.body = JSON.dump(
100
+ {
101
+ model: @model,
102
+ messages: [{ role: "user", content: prompt }],
103
+ max_completion_tokens: 10_000,
104
+ temperature: 0.3
105
+ }
106
+ )
107
+
108
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
109
+ http.request(request)
110
+ end
111
+ raise "Invalid http response #{response.code}:\n#{response.body}" unless response.is_a?(Net::HTTPSuccess)
112
+
113
+ JSON.parse(response.body)["choices"][0]["message"]["content"]
114
+ end
115
+ end
metadata ADDED
@@ -0,0 +1,75 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cowrite
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Michael Grosser
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-10-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: parallel
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: ruby-progressbar
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ description:
42
+ email: michael@grosser.it
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - MIT-LICENSE
48
+ - bin/cowrite
49
+ - lib/cowrite.rb
50
+ - lib/cowrite/cli.rb
51
+ - lib/cowrite/version.rb
52
+ homepage: https://github.com/grosser/cowrite
53
+ licenses:
54
+ - MIT
55
+ metadata: {}
56
+ post_install_message:
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: 3.1.0
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 3.4.10
72
+ signing_key:
73
+ specification_version: 4
74
+ summary: Create changes for a local repository with chatgpt / openai / local llm
75
+ test_files: []