looped 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.
@@ -0,0 +1,40 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'dspy'
5
+
6
+ module Looped
7
+ # Main coding task signature
8
+ class CodingTaskSignature < DSPy::Signature
9
+ description "Complete a coding task in any programming language."
10
+
11
+ input do
12
+ const :task, String
13
+ const :context, String, default: ''
14
+ const :history, T::Array[Types::ActionSummary], default: []
15
+ end
16
+
17
+ output do
18
+ const :solution, String
19
+ const :files_modified, T::Array[String], default: []
20
+ end
21
+ end
22
+
23
+ # LLM-as-Judge signature
24
+ class JudgeSignature < DSPy::Signature
25
+ description "Evaluate code quality and correctness. Be critical and thorough."
26
+
27
+ input do
28
+ const :task, String
29
+ const :solution, String
30
+ const :expected_behavior, String
31
+ end
32
+
33
+ output do
34
+ const :score, Float
35
+ const :passed, T::Boolean
36
+ const :critique, String
37
+ const :suggestions, T::Array[String]
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,120 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+ require 'fileutils'
6
+
7
+ module Looped
8
+ class State
9
+ extend T::Sig
10
+
11
+ DEFAULT_STORAGE_DIR = T.let(File.expand_path('~/.looped'), String)
12
+
13
+ sig { returns(String) }
14
+ attr_reader :storage_dir
15
+
16
+ sig { params(storage_dir: T.nilable(String)).void }
17
+ def initialize(storage_dir: nil)
18
+ @storage_dir = T.let(
19
+ storage_dir || ENV['LOOPED_STORAGE_DIR'] || DEFAULT_STORAGE_DIR,
20
+ String
21
+ )
22
+ FileUtils.mkdir_p(@storage_dir)
23
+ FileUtils.mkdir_p(File.join(@storage_dir, 'history'))
24
+ end
25
+
26
+ sig { returns(T.nilable(Types::Instructions)) }
27
+ def load_instructions
28
+ path = instructions_path
29
+ return nil unless File.exist?(path)
30
+
31
+ data = JSON.parse(File.read(path), symbolize_names: true)
32
+ instructions_data = data[:instructions] || {}
33
+
34
+ Types::Instructions.new(
35
+ thought_generator: instructions_data[:thought_generator],
36
+ observation_processor: instructions_data[:observation_processor],
37
+ score: data[:score]&.to_f || 0.0,
38
+ generation: data[:generation]&.to_i || 0,
39
+ updated_at: data[:updated_at] || Time.now.utc.iso8601
40
+ )
41
+ rescue JSON::ParserError
42
+ nil
43
+ end
44
+
45
+ sig { params(instructions: T::Hash[Symbol, T.nilable(String)], score: Float, generation: Integer).void }
46
+ def save_instructions(instructions:, score:, generation:)
47
+ data = {
48
+ instructions: instructions,
49
+ score: score,
50
+ generation: generation,
51
+ updated_at: Time.now.utc.iso8601
52
+ }
53
+ File.write(instructions_path, JSON.pretty_generate(data))
54
+ end
55
+
56
+ sig { params(result: Types::TrainingResult).void }
57
+ def append_training_result(result)
58
+ buffer = load_training_buffer_raw
59
+ buffer << {
60
+ task: result.task,
61
+ solution: result.solution,
62
+ score: result.score,
63
+ feedback: result.feedback,
64
+ timestamp: result.timestamp
65
+ }
66
+ File.write(training_buffer_path, JSON.pretty_generate(buffer))
67
+ end
68
+
69
+ sig { returns(T::Array[Types::TrainingResult]) }
70
+ def peek_training_buffer
71
+ load_training_buffer_raw.map { |data| deserialize_training_result(data) }
72
+ end
73
+
74
+ sig { returns(T::Array[Types::TrainingResult]) }
75
+ def consume_training_buffer
76
+ buffer = peek_training_buffer
77
+ return [] if buffer.empty?
78
+
79
+ # Archive to history
80
+ archive_path = File.join(@storage_dir, 'history', "#{Time.now.to_i}.json")
81
+ File.write(archive_path, JSON.pretty_generate(load_training_buffer_raw))
82
+
83
+ # Clear buffer
84
+ File.write(training_buffer_path, '[]')
85
+
86
+ buffer
87
+ end
88
+
89
+ private
90
+
91
+ sig { returns(String) }
92
+ def instructions_path
93
+ File.join(@storage_dir, 'instructions.json')
94
+ end
95
+
96
+ sig { returns(String) }
97
+ def training_buffer_path
98
+ File.join(@storage_dir, 'training_buffer.json')
99
+ end
100
+
101
+ sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
102
+ def load_training_buffer_raw
103
+ return [] unless File.exist?(training_buffer_path)
104
+ JSON.parse(File.read(training_buffer_path), symbolize_names: true)
105
+ rescue JSON::ParserError
106
+ []
107
+ end
108
+
109
+ sig { params(data: T::Hash[Symbol, T.untyped]).returns(Types::TrainingResult) }
110
+ def deserialize_training_result(data)
111
+ Types::TrainingResult.new(
112
+ task: data[:task] || '',
113
+ solution: data[:solution] || '',
114
+ score: data[:score]&.to_f || 0.0,
115
+ feedback: data[:feedback] || '',
116
+ timestamp: data[:timestamp] || Time.now.utc.iso8601
117
+ )
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,35 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Looped
5
+ module Tools
6
+ class ReadFile < DSPy::Tools::Base
7
+ extend T::Sig
8
+
9
+ tool_name 'read_file'
10
+ tool_description 'Read contents of a file at the given path'
11
+
12
+ sig { params(path: String).returns(String) }
13
+ def call(path:)
14
+ resolved_path = resolve_path(path)
15
+ File.read(resolved_path)
16
+ rescue Errno::ENOENT
17
+ "Error: File not found: #{path}"
18
+ rescue Errno::EACCES
19
+ "Error: Permission denied: #{path}"
20
+ rescue StandardError => e
21
+ "Error: #{e.message}"
22
+ end
23
+
24
+ private
25
+
26
+ sig { params(path: String).returns(String) }
27
+ def resolve_path(path)
28
+ sandbox = ENV['LOOPED_SANDBOX_DIR']
29
+ return path if sandbox.nil? || path.start_with?('/')
30
+
31
+ File.join(sandbox, path)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,56 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'open3'
5
+
6
+ module Looped
7
+ module Tools
8
+ class RunCommand < DSPy::Tools::Base
9
+ extend T::Sig
10
+
11
+ DEFAULT_TIMEOUT = 30
12
+
13
+ tool_name 'run_command'
14
+ tool_description 'Execute a shell command and return its output. Use for running tests, linters, or other development tools.'
15
+
16
+ sig { params(command: String, timeout: Integer).returns(String) }
17
+ def call(command:, timeout: DEFAULT_TIMEOUT)
18
+ # TODO: Implement Docker sandbox via trusted-sandbox gem for production
19
+ pid = T.let(nil, T.nilable(Integer))
20
+ output = +''
21
+
22
+ begin
23
+ stdin, stdout_err, wait_thr = Open3.popen2e(command)
24
+ pid = wait_thr.pid
25
+ stdin.close
26
+
27
+ # Wait for process with timeout using a thread
28
+ reader = Thread.new { stdout_err.read }
29
+
30
+ if wait_thr.join(timeout)
31
+ # Process completed in time
32
+ output = reader.value
33
+ exit_status = wait_thr.value
34
+ "Exit code: #{exit_status.exitstatus}\n#{output}"
35
+ else
36
+ # Timeout - kill the process
37
+ reader.kill
38
+ Process.kill('TERM', pid)
39
+ sleep 0.1
40
+ begin
41
+ Process.kill('KILL', pid) if wait_thr.alive?
42
+ rescue Errno::ESRCH
43
+ # Process already dead
44
+ end
45
+ wait_thr.join
46
+ "Error: Command timed out after #{timeout} seconds"
47
+ end
48
+ rescue StandardError => e
49
+ "Error: #{e.message}"
50
+ ensure
51
+ stdout_err&.close rescue nil
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,38 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'open3'
5
+
6
+ module Looped
7
+ module Tools
8
+ class SearchCode < DSPy::Tools::Base
9
+ extend T::Sig
10
+
11
+ tool_name 'search_code'
12
+ tool_description 'Search for a pattern in code files using ripgrep. Returns matching lines with file paths and line numbers.'
13
+
14
+ sig { params(pattern: String, path: String, file_type: T.nilable(String)).returns(String) }
15
+ def call(pattern:, path: '.', file_type: nil)
16
+ cmd = build_command(pattern, path, file_type)
17
+ output, status = Open3.capture2e(*cmd)
18
+
19
+ if status.success?
20
+ output.empty? ? "No matches found for: #{pattern}" : output
21
+ else
22
+ "No matches found for: #{pattern}"
23
+ end
24
+ rescue StandardError => e
25
+ "Error: #{e.message}"
26
+ end
27
+
28
+ private
29
+
30
+ sig { params(pattern: String, path: String, file_type: T.nilable(String)).returns(T::Array[String]) }
31
+ def build_command(pattern, path, file_type)
32
+ cmd = ['rg', '--line-number', '--no-heading', '--max-count', '50', pattern, path]
33
+ cmd += ['--type', file_type] if file_type
34
+ cmd
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,37 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'fileutils'
5
+
6
+ module Looped
7
+ module Tools
8
+ class WriteFile < DSPy::Tools::Base
9
+ extend T::Sig
10
+
11
+ tool_name 'write_file'
12
+ tool_description 'Write content to a file at the given path. Creates parent directories if needed.'
13
+
14
+ sig { params(path: String, content: String).returns(String) }
15
+ def call(path:, content:)
16
+ resolved_path = resolve_path(path)
17
+ FileUtils.mkdir_p(File.dirname(resolved_path))
18
+ File.write(resolved_path, content)
19
+ "Successfully wrote #{content.length} bytes to #{path}"
20
+ rescue Errno::EACCES
21
+ "Error: Permission denied: #{path}"
22
+ rescue StandardError => e
23
+ "Error: #{e.message}"
24
+ end
25
+
26
+ private
27
+
28
+ sig { params(path: String).returns(String) }
29
+ def resolve_path(path)
30
+ sandbox = ENV['LOOPED_SANDBOX_DIR']
31
+ return path if sandbox.nil? || path.start_with?('/')
32
+
33
+ File.join(sandbox, path)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,53 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ module Looped
7
+ module Types
8
+ extend T::Sig
9
+
10
+ # Rich memory entry for storage/analytics
11
+ class MemoryEntry < T::Struct
12
+ const :action_type, String
13
+ const :action_input, T::Hash[String, T.untyped]
14
+ const :action_output, String
15
+ const :timestamp, String
16
+ const :model_id, T.nilable(String)
17
+ const :error, T.nilable(String)
18
+ const :tokens_used, T.nilable(Integer)
19
+ end
20
+
21
+ # Lean context entry for prompts
22
+ class ActionSummary < T::Struct
23
+ const :action, String
24
+ const :result, String
25
+ end
26
+
27
+ # Training result stored to buffer
28
+ class TrainingResult < T::Struct
29
+ const :task, String
30
+ const :solution, String
31
+ const :score, Float
32
+ const :feedback, String
33
+ const :timestamp, String
34
+ end
35
+
36
+ # Persisted instructions with metadata
37
+ class Instructions < T::Struct
38
+ const :thought_generator, T.nilable(String)
39
+ const :observation_processor, T.nilable(String)
40
+ const :score, Float, default: 0.0
41
+ const :generation, Integer, default: 0
42
+ const :updated_at, String
43
+ end
44
+
45
+ # Judgment from LLM-as-judge
46
+ class Judgment < T::Struct
47
+ const :score, Float
48
+ const :passed, T::Boolean
49
+ const :critique, String
50
+ const :suggestions, T::Array[String], default: []
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,6 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Looped
5
+ VERSION = '0.1.0'
6
+ end
data/lib/looped.rb ADDED
@@ -0,0 +1,100 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+ require 'dspy'
6
+ require 'dspy/openai'
7
+
8
+ # Load GEPA extension
9
+ begin
10
+ require 'dspy/teleprompt/gepa'
11
+ rescue LoadError
12
+ # GEPA not available - optimization will be limited
13
+ end
14
+
15
+ require_relative 'looped/version'
16
+ require_relative 'looped/types'
17
+ require_relative 'looped/signatures'
18
+ require_relative 'looped/memory'
19
+ require_relative 'looped/state'
20
+
21
+ # Tools
22
+ require_relative 'looped/tools/read_file'
23
+ require_relative 'looped/tools/write_file'
24
+ require_relative 'looped/tools/search_code'
25
+ require_relative 'looped/tools/run_command'
26
+
27
+ require_relative 'looped/judge'
28
+ require_relative 'looped/agent'
29
+ require_relative 'looped/optimizer'
30
+ require_relative 'looped/application'
31
+
32
+ module Looped
33
+ extend T::Sig
34
+
35
+ class Error < StandardError; end
36
+
37
+ # Convenience method to create and run the application
38
+ sig do
39
+ params(
40
+ model: T.nilable(String),
41
+ judge_model: T.nilable(String),
42
+ reflection_model: T.nilable(String),
43
+ max_iterations: Integer,
44
+ optimizer_batch_size: Integer,
45
+ optimizer_interval: Integer
46
+ ).void
47
+ end
48
+ def self.run(
49
+ model: nil,
50
+ judge_model: nil,
51
+ reflection_model: nil,
52
+ max_iterations: 10,
53
+ optimizer_batch_size: 5,
54
+ optimizer_interval: 60
55
+ )
56
+ app = Application.new(
57
+ model: model,
58
+ judge_model: judge_model,
59
+ reflection_model: reflection_model,
60
+ max_iterations: max_iterations,
61
+ optimizer_batch_size: optimizer_batch_size,
62
+ optimizer_interval: optimizer_interval
63
+ )
64
+ app.run
65
+ end
66
+
67
+ # Run a single task without the interactive loop
68
+ sig do
69
+ params(
70
+ task: String,
71
+ context: String,
72
+ model: T.nilable(String),
73
+ judge_model: T.nilable(String),
74
+ reflection_model: T.nilable(String),
75
+ max_iterations: Integer,
76
+ optimizer_batch_size: Integer,
77
+ optimizer_interval: Integer
78
+ ).returns(Types::TrainingResult)
79
+ end
80
+ def self.execute(
81
+ task:,
82
+ context: '',
83
+ model: nil,
84
+ judge_model: nil,
85
+ reflection_model: nil,
86
+ max_iterations: 10,
87
+ optimizer_batch_size: 5,
88
+ optimizer_interval: 60
89
+ )
90
+ app = Application.new(
91
+ model: model,
92
+ judge_model: judge_model,
93
+ reflection_model: reflection_model,
94
+ max_iterations: max_iterations,
95
+ optimizer_batch_size: optimizer_batch_size,
96
+ optimizer_interval: optimizer_interval
97
+ )
98
+ app.run_task(task: task, context: context)
99
+ end
100
+ end
data/looped.gemspec ADDED
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/looped/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'looped'
7
+ spec.version = Looped::VERSION
8
+ spec.authors = ['Vicente Reig']
9
+ spec.email = ['hey@vicente.services']
10
+
11
+ spec.summary = 'Self-improving coding agent with continuous prompt optimization'
12
+ spec.description = 'A coding agent that learns from its own performance using GEPA prompt optimization running in the background.'
13
+ spec.homepage = 'https://github.com/vicentereig/looped'
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = '>= 3.1.0'
16
+
17
+ spec.metadata['homepage_uri'] = spec.homepage
18
+ spec.metadata['source_code_uri'] = 'https://github.com/vicentereig/looped'
19
+ spec.metadata['changelog_uri'] = 'https://github.com/vicentereig/looped/blob/main/CHANGELOG.md'
20
+
21
+ spec.files = Dir.chdir(__dir__) do
22
+ `git ls-files -z`.split("\x0").reject do |f|
23
+ (File.expand_path(f) == __FILE__) ||
24
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile])
25
+ end
26
+ end
27
+ spec.bindir = 'exe'
28
+ spec.executables = ['looped']
29
+ spec.require_paths = ['lib']
30
+
31
+ # Runtime dependencies
32
+ spec.add_dependency 'async', '~> 2.23'
33
+ spec.add_dependency 'dspy', '~> 0.31.1'
34
+ spec.add_dependency 'dspy-openai', '~> 1.0'
35
+ spec.add_dependency 'dspy-gepa', '~> 1.0.3'
36
+ spec.add_dependency 'gepa', '~> 1.0.2'
37
+ spec.add_dependency 'polars-df', '~> 0.23'
38
+ spec.add_dependency 'sorbet-runtime', '~> 0.5'
39
+
40
+ # Development dependencies
41
+ spec.add_development_dependency 'bundler', '~> 2.0'
42
+ spec.add_development_dependency 'rake', '~> 13.0'
43
+ spec.add_development_dependency 'rspec', '~> 3.12'
44
+ spec.add_development_dependency 'vcr', '~> 6.2'
45
+ spec.add_development_dependency 'webmock', '~> 3.18'
46
+ spec.add_development_dependency 'byebug', '~> 11.1'
47
+ end