ollama_agent 0.3.0 → 1.0.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +14 -3
  4. data/lib/ollama_agent/agent/agent_config.rb +19 -2
  5. data/lib/ollama_agent/agent/client_wiring.rb +3 -8
  6. data/lib/ollama_agent/agent/session_wiring.rb +37 -3
  7. data/lib/ollama_agent/agent.rb +82 -6
  8. data/lib/ollama_agent/cli/repl.rb +159 -0
  9. data/lib/ollama_agent/cli/repl_shared.rb +229 -0
  10. data/lib/ollama_agent/cli/tui_repl.rb +149 -0
  11. data/lib/ollama_agent/cli.rb +129 -49
  12. data/lib/ollama_agent/core/action_envelope.rb +82 -0
  13. data/lib/ollama_agent/core/budget.rb +90 -0
  14. data/lib/ollama_agent/core/loop_detector.rb +67 -0
  15. data/lib/ollama_agent/core/schema_validator.rb +136 -0
  16. data/lib/ollama_agent/core/trace_logger.rb +138 -0
  17. data/lib/ollama_agent/external_agents/probe.rb +23 -3
  18. data/lib/ollama_agent/indexing/context_packer.rb +140 -0
  19. data/lib/ollama_agent/indexing/diff_summarizer.rb +125 -0
  20. data/lib/ollama_agent/indexing/file_indexer.rb +129 -0
  21. data/lib/ollama_agent/indexing/repo_scanner.rb +158 -0
  22. data/lib/ollama_agent/memory/long_term.rb +109 -0
  23. data/lib/ollama_agent/memory/manager.rb +121 -0
  24. data/lib/ollama_agent/memory/session_memory.rb +93 -0
  25. data/lib/ollama_agent/memory/short_term.rb +66 -0
  26. data/lib/ollama_agent/ollama_cloud_catalog.rb +66 -0
  27. data/lib/ollama_agent/ollama_connection.rb +30 -0
  28. data/lib/ollama_agent/plugins/loader.rb +95 -0
  29. data/lib/ollama_agent/plugins/registry.rb +103 -0
  30. data/lib/ollama_agent/providers/anthropic.rb +245 -0
  31. data/lib/ollama_agent/providers/base.rb +79 -0
  32. data/lib/ollama_agent/providers/ollama.rb +118 -0
  33. data/lib/ollama_agent/providers/openai.rb +215 -0
  34. data/lib/ollama_agent/providers/registry.rb +76 -0
  35. data/lib/ollama_agent/providers/router.rb +93 -0
  36. data/lib/ollama_agent/resilience/retry_middleware.rb +5 -0
  37. data/lib/ollama_agent/runner.rb +25 -4
  38. data/lib/ollama_agent/runtime/approval_gate.rb +74 -0
  39. data/lib/ollama_agent/runtime/permissions.rb +103 -0
  40. data/lib/ollama_agent/runtime/policies.rb +100 -0
  41. data/lib/ollama_agent/runtime/sandbox.rb +130 -0
  42. data/lib/ollama_agent/streaming/hooks.rb +3 -1
  43. data/lib/ollama_agent/tools/base.rb +108 -0
  44. data/lib/ollama_agent/tools/git_tools.rb +176 -0
  45. data/lib/ollama_agent/tools/http_tools.rb +202 -0
  46. data/lib/ollama_agent/tools/memory_tools.rb +116 -0
  47. data/lib/ollama_agent/tools/shell_tools.rb +208 -0
  48. data/lib/ollama_agent/tui.rb +183 -0
  49. data/lib/ollama_agent/tui_slash_reader.rb +147 -0
  50. data/lib/ollama_agent/tui_user_prompt.rb +45 -0
  51. data/lib/ollama_agent/version.rb +1 -1
  52. data/lib/ollama_agent.rb +46 -1
  53. metadata +142 -5
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ module Core
5
+ # Wraps every structured response from the planner.
6
+ # Enforces a fixed contract so the runner stays deterministic.
7
+ #
8
+ # Types:
9
+ # :tool_call — execute a tool with args
10
+ # :final — model is done; surface content to the user
11
+ # :ask_clarification— model needs more info before proceeding
12
+ # :error — unrecoverable error in the action
13
+ # :handoff — delegate to another agent
14
+ class ActionEnvelope
15
+ VALID_TYPES = %i[tool_call final ask_clarification error handoff].freeze
16
+
17
+ attr_reader :type, :payload, :confidence, :envelope_id
18
+
19
+ def initialize(type:, payload:, confidence: nil, envelope_id: nil)
20
+ raise ArgumentError, "Unknown action type: #{type}" unless VALID_TYPES.include?(type.to_sym)
21
+
22
+ @type = type.to_sym
23
+ @payload = payload
24
+ @confidence = confidence
25
+ @envelope_id = envelope_id || generate_id
26
+ end
27
+
28
+ # --- Factory constructors ---
29
+
30
+ def self.tool_call(tool:, args:, confidence: nil)
31
+ new(type: :tool_call, payload: { tool: tool.to_s, args: args || {} }, confidence: confidence)
32
+ end
33
+
34
+ def self.final(content:)
35
+ new(type: :final, payload: { content: content.to_s })
36
+ end
37
+
38
+ def self.ask_clarification(question:)
39
+ new(type: :ask_clarification, payload: { question: question.to_s })
40
+ end
41
+
42
+ def self.error(message:, cause: nil)
43
+ new(type: :error, payload: { message: message.to_s, cause: cause })
44
+ end
45
+
46
+ def self.handoff(agent:, query:)
47
+ new(type: :handoff, payload: { agent: agent.to_s, query: query.to_s })
48
+ end
49
+
50
+ # --- Predicate helpers ---
51
+
52
+ def tool_call? = @type == :tool_call
53
+ def final? = @type == :final
54
+ def ask_clarification? = @type == :ask_clarification
55
+ def error? = @type == :error
56
+ def handoff? = @type == :handoff
57
+
58
+ # --- Accessors for common payload fields ---
59
+
60
+ def tool = @payload[:tool]
61
+ def args = @payload[:args] || {}
62
+ def content = @payload[:content]
63
+ def question = @payload[:question]
64
+ def message = @payload[:message]
65
+
66
+ def to_h
67
+ { type: @type, payload: @payload, confidence: @confidence, envelope_id: @envelope_id }
68
+ end
69
+
70
+ def to_s
71
+ "#<ActionEnvelope type=#{@type} id=#{@envelope_id}>"
72
+ end
73
+
74
+ private
75
+
76
+ def generate_id
77
+ require "securerandom"
78
+ "env_#{SecureRandom.hex(6)}"
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ module Core
5
+ # Tracks and enforces token, step, and cost budgets for an agent run.
6
+ # Instantiate once per run; call #record_step! after each model round-trip.
7
+ class Budget
8
+ DEFAULT_MAX_STEPS = 64
9
+ DEFAULT_MAX_TOKENS = 32_768
10
+ DEFAULT_MAX_COST_USD = nil
11
+
12
+ attr_reader :steps, :tokens_used, :cost_usd,
13
+ :max_steps, :max_tokens, :max_cost_usd
14
+
15
+ def initialize(max_steps: nil, max_tokens: nil, max_cost_usd: nil)
16
+ @max_steps = max_steps || env_int("OLLAMA_AGENT_MAX_TURNS", DEFAULT_MAX_STEPS)
17
+ @max_tokens = max_tokens || env_int("OLLAMA_AGENT_MAX_TOKENS", DEFAULT_MAX_TOKENS)
18
+ @max_cost_usd = max_cost_usd || env_float("OLLAMA_AGENT_MAX_COST_USD", DEFAULT_MAX_COST_USD)
19
+
20
+ reset!
21
+ end
22
+
23
+ # Record one agent step. Call after each model response.
24
+ # @param tokens [Integer] tokens consumed in this step (prompt + completion)
25
+ # @param cost_usd [Float] estimated cost in USD (0.0 for local models)
26
+ def record_step!(tokens: 0, cost_usd: 0.0)
27
+ @steps += 1
28
+ @tokens_used += tokens.to_i
29
+ @cost_usd += cost_usd.to_f
30
+ nil
31
+ end
32
+
33
+ def steps_exceeded? = @steps >= @max_steps
34
+ def tokens_exceeded? = @tokens_used >= @max_tokens
35
+ def cost_exceeded? = !@max_cost_usd.nil? && @cost_usd >= @max_cost_usd
36
+
37
+ # True when any limit has been hit.
38
+ def exceeded?
39
+ steps_exceeded? || tokens_exceeded? || cost_exceeded?
40
+ end
41
+
42
+ # Human-readable reason for the first exceeded limit, or nil if none.
43
+ def exceeded_reason
44
+ return "step limit (#{@max_steps})" if steps_exceeded?
45
+ return "token limit (#{@max_tokens})" if tokens_exceeded?
46
+ return "cost limit ($#{@max_cost_usd})" if cost_exceeded?
47
+
48
+ nil
49
+ end
50
+
51
+ def reset!
52
+ @steps = 0
53
+ @tokens_used = 0
54
+ @cost_usd = 0.0
55
+ end
56
+
57
+ def to_h
58
+ {
59
+ steps: @steps, max_steps: @max_steps,
60
+ tokens_used: @tokens_used, max_tokens: @max_tokens,
61
+ cost_usd: @cost_usd, max_cost_usd: @max_cost_usd
62
+ }
63
+ end
64
+
65
+ def remaining_steps
66
+ [@max_steps - @steps, 0].max
67
+ end
68
+
69
+ private
70
+
71
+ def env_int(key, default)
72
+ v = ENV.fetch(key, nil)
73
+ return default if v.nil? || v.strip.empty?
74
+
75
+ Integer(v)
76
+ rescue ArgumentError, TypeError
77
+ default
78
+ end
79
+
80
+ def env_float(key, default)
81
+ v = ENV.fetch(key, nil)
82
+ return default if v.nil? || v.strip.empty?
83
+
84
+ Float(v)
85
+ rescue ArgumentError, TypeError
86
+ default
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ module Core
5
+ # Detects when the agent is stuck in a repeating tool-call loop.
6
+ #
7
+ # Strategy: keep a sliding window of (tool_name + args_fingerprint) tokens.
8
+ # If the most-recent window pattern appears THRESHOLD or more times in the
9
+ # accumulated history, we declare a loop.
10
+ class LoopDetector
11
+ DEFAULT_WINDOW = 4 # number of consecutive calls to treat as one pattern
12
+ DEFAULT_THRESHOLD = 2 # how many times the pattern must repeat
13
+
14
+ attr_reader :history
15
+
16
+ def initialize(window: DEFAULT_WINDOW, threshold: DEFAULT_THRESHOLD)
17
+ @window = window.to_i.clamp(1, 32)
18
+ @threshold = threshold.to_i.clamp(2, 16)
19
+ @history = []
20
+ end
21
+
22
+ # Record a tool call. Should be called before executing each tool.
23
+ # @param tool_name [String]
24
+ # @param args [Hash, String]
25
+ def record!(tool_name, args = {})
26
+ @history << fingerprint(tool_name, args)
27
+ end
28
+
29
+ # Returns true when the recent pattern has repeated enough times.
30
+ def loop_detected?
31
+ return false if @history.size < @window * @threshold
32
+
33
+ pattern = @history.last(@window)
34
+ matches = 0
35
+
36
+ (@history.size - @window + 1).times do |i|
37
+ matches += 1 if @history[i, @window] == pattern
38
+ end
39
+
40
+ matches >= @threshold
41
+ end
42
+
43
+ # Human-readable description of the detected loop.
44
+ def loop_summary
45
+ return nil unless loop_detected?
46
+
47
+ pattern = @history.last(@window)
48
+ "Loop detected: pattern [#{pattern.join(" → ")}] repeated #{@threshold}+ times"
49
+ end
50
+
51
+ def reset!
52
+ @history.clear
53
+ end
54
+
55
+ private
56
+
57
+ def fingerprint(tool_name, args)
58
+ stable = case args
59
+ when Hash then args.sort.map { |k, v| "#{k}=#{v}" }.join(",")
60
+ when Array then args.map(&:to_s).join(",")
61
+ else args.to_s
62
+ end
63
+ "#{tool_name}(#{stable[0, 80]})"
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ module Core
5
+ # Lightweight JSON-schema validator for tool arguments.
6
+ # Supports: type, required, properties, enum, minimum, maximum, minLength, maxLength.
7
+ # Does NOT require the json-schema gem — all validation is hand-rolled for zero dependencies.
8
+ class SchemaValidator
9
+ ValidationError = Class.new(StandardError)
10
+
11
+ # Validate +data+ against +schema+.
12
+ # @param schema [Hash] JSON schema (symbol or string keys)
13
+ # @param data [Hash] data to validate
14
+ # @return [Array<String>] list of error messages (empty = valid)
15
+ def validate(schema, data)
16
+ @errors = []
17
+ schema = stringify_keys(schema)
18
+ data = stringify_keys(data || {})
19
+
20
+ check_type(schema, data)
21
+ check_required(schema, data)
22
+ check_properties(schema, data)
23
+
24
+ @errors.dup
25
+ end
26
+
27
+ # Raises ValidationError if any errors found.
28
+ def validate!(schema, data)
29
+ errors = validate(schema, data)
30
+ raise ValidationError, errors.join("; ") if errors.any?
31
+
32
+ true
33
+ end
34
+
35
+ private
36
+
37
+ def check_type(schema, data)
38
+ expected = schema["type"]
39
+ return if expected.nil?
40
+
41
+ actual = ruby_type(data)
42
+ unless type_match?(expected, actual, data)
43
+ @errors << "expected type #{expected}, got #{actual}"
44
+ end
45
+ end
46
+
47
+ def check_required(schema, data)
48
+ required = schema["required"]
49
+ return unless required.is_a?(Array)
50
+
51
+ required.each do |field|
52
+ @errors << "missing required field: #{field}" unless data.key?(field.to_s)
53
+ end
54
+ end
55
+
56
+ def check_properties(schema, data)
57
+ props = schema["properties"]
58
+ return unless props.is_a?(Hash)
59
+
60
+ props.each do |prop_name, prop_schema|
61
+ next unless data.key?(prop_name.to_s)
62
+
63
+ value = data[prop_name.to_s]
64
+ prop_errors = self.class.new.validate(prop_schema, value)
65
+ prop_errors.each { |e| @errors << "#{prop_name}: #{e}" }
66
+ check_constraints(prop_name, prop_schema, value)
67
+ end
68
+ end
69
+
70
+ def check_constraints(name, schema, value)
71
+ check_enum(name, schema, value)
72
+ check_string_length(name, schema, value)
73
+ check_numeric_range(name, schema, value)
74
+ end
75
+
76
+ def check_enum(name, schema, value)
77
+ allowed = schema["enum"]
78
+ return unless allowed.is_a?(Array)
79
+ return if allowed.include?(value)
80
+
81
+ @errors << "#{name}: must be one of #{allowed.inspect}, got #{value.inspect}"
82
+ end
83
+
84
+ def check_string_length(name, schema, value)
85
+ return unless value.is_a?(String)
86
+
87
+ min_len = schema["minLength"]
88
+ max_len = schema["maxLength"]
89
+
90
+ if min_len && value.length < min_len
91
+ @errors << "#{name}: length #{value.length} is less than minLength #{min_len}"
92
+ end
93
+ if max_len && value.length > max_len
94
+ @errors << "#{name}: length #{value.length} exceeds maxLength #{max_len}"
95
+ end
96
+ end
97
+
98
+ def check_numeric_range(name, schema, value)
99
+ return unless value.is_a?(Numeric)
100
+
101
+ minimum = schema["minimum"]
102
+ maximum = schema["maximum"]
103
+
104
+ @errors << "#{name}: #{value} is less than minimum #{minimum}" if minimum && value < minimum
105
+ @errors << "#{name}: #{value} exceeds maximum #{maximum}" if maximum && value > maximum
106
+ end
107
+
108
+ def ruby_type(value)
109
+ case value
110
+ when Hash then "object"
111
+ when Array then "array"
112
+ when String then "string"
113
+ when Integer then "integer"
114
+ when Float then "number"
115
+ when TrueClass, FalseClass then "boolean"
116
+ when NilClass then "null"
117
+ else "unknown"
118
+ end
119
+ end
120
+
121
+ def type_match?(expected, actual, data)
122
+ return actual == expected unless expected == "number"
123
+
124
+ data.is_a?(Numeric)
125
+ end
126
+
127
+ def stringify_keys(obj)
128
+ return obj unless obj.is_a?(Hash)
129
+
130
+ obj.transform_keys(&:to_s).transform_values do |v|
131
+ v.is_a?(Hash) ? stringify_keys(v) : v
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "securerandom"
6
+
7
+ module OllamaAgent
8
+ module Core
9
+ # Structured observability logger for agent runs.
10
+ # Tracks: run_id, step_id, tool_call_id, latency, token usage,
11
+ # retry count, fallback usage, schema failures, user approvals.
12
+ #
13
+ # Supports three output modes (set via :format):
14
+ # :human — colored, readable (default)
15
+ # :json — NDJSON file under log_dir
16
+ # :debug — human + full payload dump
17
+ class TraceLogger
18
+ FORMATS = %i[human json debug].freeze
19
+
20
+ attr_reader :run_id
21
+
22
+ def initialize(log_dir: nil, format: :human, hooks: nil)
23
+ @log_dir = log_dir
24
+ @format = FORMATS.include?(format.to_sym) ? format.to_sym : :human
25
+ @hooks = hooks
26
+ @run_id = "run_#{SecureRandom.hex(8)}"
27
+ @step = 0
28
+
29
+ attach_to_hooks if @hooks
30
+ end
31
+
32
+ # Emit a structured trace event.
33
+ # @param event [Symbol] event name
34
+ # @param payload [Hash]
35
+ def trace(event, payload = {})
36
+ entry = build_entry(event, payload)
37
+ write_entry(entry)
38
+ entry
39
+ end
40
+
41
+ def start_run(query: nil)
42
+ trace(:run_start, { query: query, run_id: @run_id })
43
+ end
44
+
45
+ def end_run(turns:, budget: nil)
46
+ trace(:run_end, { turns: turns, budget: budget&.to_h, run_id: @run_id })
47
+ end
48
+
49
+ def tool_call(name:, args:, turn:, call_id: nil)
50
+ @step += 1
51
+ trace(:tool_call, { name: name, args: args, turn: turn,
52
+ step: @step, call_id: call_id || step_id })
53
+ end
54
+
55
+ def tool_result(name:, result:, turn:, latency_ms: nil, call_id: nil)
56
+ trace(:tool_result, { name: name, bytes: result.to_s.bytesize,
57
+ turn: turn, latency_ms: latency_ms, call_id: call_id })
58
+ end
59
+
60
+ def schema_failure(tool:, errors:)
61
+ trace(:schema_failure, { tool: tool, errors: errors })
62
+ end
63
+
64
+ def user_approval(tool:, approved:)
65
+ trace(:user_approval, { tool: tool, approved: approved })
66
+ end
67
+
68
+ def retry_attempt(attempt:, delay_ms:, error:)
69
+ trace(:http_retry, { attempt: attempt, delay_ms: delay_ms,
70
+ error: error.class.name, message: error.message })
71
+ end
72
+
73
+ def fallback_used(from_provider:, to_provider:, reason:)
74
+ trace(:provider_fallback, { from: from_provider, to: to_provider, reason: reason })
75
+ end
76
+
77
+ def loop_detected(summary:)
78
+ trace(:loop_detected, { summary: summary })
79
+ end
80
+
81
+ def budget_exceeded(reason:)
82
+ trace(:budget_exceeded, { reason: reason })
83
+ end
84
+
85
+ private
86
+
87
+ def attach_to_hooks
88
+ @hooks.on(:on_tool_call) { |p| tool_call(name: p[:name], args: p[:args], turn: p[:turn]) }
89
+ @hooks.on(:on_tool_result) { |p| tool_result(name: p[:name], result: p[:result], turn: p[:turn]) }
90
+ @hooks.on(:on_retry) { |p| retry_attempt(attempt: p[:attempt], delay_ms: p[:delay_ms], error: p[:error]) }
91
+ @hooks.on(:on_complete) { |p| end_run(turns: p[:turns]) }
92
+ @hooks.on(:on_error) { |p| trace(:agent_error, { error: p[:error].class.name, message: p[:error].message }) }
93
+ end
94
+
95
+ def build_entry(event, payload)
96
+ { ts: timestamp, run_id: @run_id, event: event }.merge(payload)
97
+ end
98
+
99
+ def write_entry(entry)
100
+ case @format
101
+ when :json then write_json(entry)
102
+ when :debug then write_debug(entry)
103
+ else write_human(entry)
104
+ end
105
+ rescue StandardError
106
+ nil
107
+ end
108
+
109
+ def write_json(entry)
110
+ return unless @log_dir
111
+
112
+ FileUtils.mkdir_p(@log_dir)
113
+ path = File.join(@log_dir, "#{Date.today}.trace.ndjson")
114
+ File.open(path, "a", encoding: Encoding::UTF_8) { |f| f.puts(JSON.generate(entry)) }
115
+ end
116
+
117
+ def write_human(entry)
118
+ event = entry[:event].to_s.ljust(20)
119
+ detail = entry.reject { |k, _| %i[ts run_id event].include?(k) }
120
+ .map { |k, v| "#{k}=#{v.inspect}" }.join(" ")
121
+ warn "[#{entry[:ts]}] #{event} #{detail}" if ENV["OLLAMA_AGENT_TRACE"] == "1"
122
+ end
123
+
124
+ def write_debug(entry)
125
+ warn "[TRACE] #{JSON.generate(entry)}" if ENV["OLLAMA_AGENT_DEBUG"] == "1"
126
+ write_json(entry) if @log_dir
127
+ end
128
+
129
+ def timestamp
130
+ Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
131
+ end
132
+
133
+ def step_id
134
+ "step_#{@run_id}_#{@step}"
135
+ end
136
+ end
137
+ end
138
+ end
@@ -21,10 +21,10 @@ module OllamaAgent
21
21
  name = agent["binary"].to_s
22
22
  return nil if name.empty?
23
23
 
24
- out, status = Open3.capture2("command", "-v", name)
25
- return out.strip if status.success? && !out.to_s.strip.empty?
24
+ resolved = resolve_via_command_v(name)
25
+ return resolved if resolved
26
26
 
27
- nil
27
+ resolve_via_path_walk(name)
28
28
  end
29
29
  # rubocop:enable Metrics/AbcSize
30
30
 
@@ -113,6 +113,26 @@ module OllamaAgent
113
113
 
114
114
  private
115
115
 
116
+ # POSIX `command -v` when /usr/bin/command exists; rescues ENOENT when `command` is shell-only.
117
+ def resolve_via_command_v(name)
118
+ out, status = Open3.capture2("command", "-v", name)
119
+ return out.strip if status.success? && !out.to_s.strip.empty?
120
+
121
+ nil
122
+ rescue Errno::ENOENT
123
+ nil
124
+ end
125
+
126
+ def resolve_via_path_walk(name)
127
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
128
+ next if dir.empty?
129
+
130
+ abs = File.join(dir, name)
131
+ return abs if File.file?(abs) && File.executable?(abs)
132
+ end
133
+ nil
134
+ end
135
+
116
136
  def status_cache
117
137
  @status_cache ||= {}
118
138
  end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "repo_scanner"
4
+ require_relative "file_indexer"
5
+
6
+ module OllamaAgent
7
+ module Indexing
8
+ # Builds a compact, query-relevant context block from the repository.
9
+ # Used to inject surgical context into the system prompt instead of
10
+ # reading entire files blindly.
11
+ #
12
+ # @example
13
+ # packer = OllamaAgent::Indexing::ContextPacker.new(root: Dir.pwd)
14
+ # context = packer.pack(query: "fix the authentication module")
15
+ # # => "# lib/auth/session.rb\n```ruby\n...\n```\n\n# lib/auth/token.rb\n..."
16
+ class ContextPacker
17
+ DEFAULT_MAX_FILES = 15
18
+ DEFAULT_MAX_FILE_BYTES = 8_192 # 8 KB per file in context
19
+ DEFAULT_MAX_TOTAL_BYTES = 65_536 # 64 KB total context
20
+
21
+ def initialize(root:, max_files: DEFAULT_MAX_FILES,
22
+ max_file_bytes: DEFAULT_MAX_FILE_BYTES,
23
+ max_total_bytes: DEFAULT_MAX_TOTAL_BYTES,
24
+ indexer: nil)
25
+ @root = File.expand_path(root)
26
+ @max_files = max_files
27
+ @max_file_bytes = max_file_bytes
28
+ @max_total_bytes = max_total_bytes
29
+ @indexer = indexer || FileIndexer.new(root: @root)
30
+ end
31
+
32
+ # Pack the most relevant files for a query.
33
+ # @param query [String] natural-language query for scoring
34
+ # @param files [Array, nil] explicit relative paths (bypasses scoring)
35
+ # @param languages [Array, nil] filter to specific languages
36
+ # @return [String] formatted context block (Markdown fenced code)
37
+ def pack(query: nil, files: nil, languages: nil)
38
+ candidates = if files
39
+ files.map { |f| File.expand_path(f, @root) }
40
+ elsif query
41
+ relevant_paths(query, languages: languages)
42
+ else
43
+ recently_modified_paths
44
+ end
45
+
46
+ build_context(candidates)
47
+ end
48
+
49
+ # Return a repo-summary block (structure, file counts, recent changes).
50
+ def repo_summary
51
+ scanner = RepoScanner.new(root: @root)
52
+ stats = scanner.stats
53
+
54
+ lines = ["## Repository Summary", "Root: #{@root}", ""]
55
+ lines << "### File counts by language"
56
+ stats[:languages]
57
+ .sort_by { |_, v| -v[:files] }
58
+ .first(10)
59
+ .each { |lang, info| lines << "- #{lang}: #{info[:files]} files (#{human_bytes(info[:bytes])})" }
60
+
61
+ lines << ""
62
+ lines << "### Recently modified (top 10)"
63
+ scanner.recently_modified(n: 10).each do |f|
64
+ lines << "- #{f.relative_path} (#{human_bytes(f.size)})"
65
+ end
66
+
67
+ lines.join("\n")
68
+ end
69
+
70
+ private
71
+
72
+ def relevant_paths(query, languages: nil)
73
+ entries = @indexer.search(query, top_n: @max_files, languages: languages)
74
+ entries.map { |e| File.join(@root, e.relative_path) }
75
+ end
76
+
77
+ def recently_modified_paths
78
+ scanner = RepoScanner.new(root: @root)
79
+ scanner.recently_modified(n: @max_files).map(&:path)
80
+ end
81
+
82
+ def build_context(paths)
83
+ parts = []
84
+ total_bytes = 0
85
+
86
+ paths.each do |abs_path|
87
+ break if total_bytes >= @max_total_bytes
88
+ break if parts.size >= @max_files
89
+
90
+ next unless File.file?(abs_path)
91
+
92
+ rel = abs_path.sub("#{@root}/", "")
93
+ content = read_truncated(abs_path)
94
+ lang = detect_fence_lang(abs_path)
95
+
96
+ block = "### #{rel}\n```#{lang}\n#{content}\n```"
97
+ block_bytes = block.bytesize
98
+
99
+ break if total_bytes + block_bytes > @max_total_bytes
100
+
101
+ parts << block
102
+ total_bytes += block_bytes
103
+ end
104
+
105
+ parts.empty? ? "" : parts.join("\n\n")
106
+ end
107
+
108
+ def read_truncated(path)
109
+ raw = File.read(path, encoding: "utf-8", invalid: :replace)
110
+ if raw.bytesize > @max_file_bytes
111
+ raw.byteslice(0, @max_file_bytes) + "\n# ... [truncated — #{human_bytes(raw.bytesize)} total]"
112
+ else
113
+ raw
114
+ end
115
+ rescue StandardError
116
+ "[unreadable]"
117
+ end
118
+
119
+ def detect_fence_lang(path)
120
+ ext_map = {
121
+ ".rb" => "ruby", ".js" => "javascript", ".ts" => "typescript",
122
+ ".py" => "python", ".go" => "go", ".rs" => "rust", ".java" => "java",
123
+ ".sh" => "bash", ".yml" => "yaml", ".yaml" => "yaml",
124
+ ".json" => "json", ".md" => "markdown", ".html" => "html",
125
+ ".css" => "css", ".sql" => "sql", ".ex" => "elixir", ".exs" => "elixir"
126
+ }
127
+ ext_map[File.extname(path).downcase] || ""
128
+ end
129
+
130
+ def human_bytes(n)
131
+ return "#{n} B" if n < 1024
132
+
133
+ n /= 1024.0
134
+ return "#{n.round(1)} KB" if n < 1024
135
+
136
+ "#{(n / 1024.0).round(1)} MB"
137
+ end
138
+ end
139
+ end
140
+ end