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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c3518f124795a2efb13b9e88abb2b7c90f935c918da3c7d30458ff2fff245f1b
4
- data.tar.gz: 101d5e4bdbfbd5e6196e9744c94f1803c1ca32fea746b275cda8ad30783788d1
3
+ metadata.gz: 69d5c209f38109fb75cd33524df7cf31af937dbaa5b7b0c493973c6e7cc16c41
4
+ data.tar.gz: ef92101a3923debec6b193b1c9e56d1817e6154b44874daa0364a564d2336724
5
5
  SHA512:
6
- metadata.gz: 1aad321bf07bb4ddea9daffeca46724e842408ac3f5d96997d77c3f81b30f10a3b1d28a4f8260bc14cde249cb63069e3fcc5aa1aecc982c0852a979733a2b1b3
7
- data.tar.gz: 5d67531648baf16bf30b63e9d3726f14243d19d939f56875fb245254ad9538eded9ea4c1fe3e38cf1ded9d93b77f392f1ac4145ae0e351b654a531802696f230
6
+ metadata.gz: 6f71a58b11c1a16eb5310313c8e3036323684861bd594c4c1dfc114504ca9f4a4b6cec3cef0309476d9be607aafd09fbaf01d4c5f8cea77b789708b7f8874ffe
7
+ data.tar.gz: e3f9747d242a68858beca29fc643711057f04d36f0bbd57929001b4feae56d8b6acd45969abf1adc64b57522f571e1f3e254142896c82b398cc21f0f9bced0e1
data/CHANGELOG.md CHANGED
@@ -1,8 +1,26 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.0.0] - 2026-04-11
4
+
5
+ First **stable** release under [Semantic Versioning](https://semver.org/): within the **`1.x`** series, the documented public Ruby API and CLI behavior are intended to remain **backwards compatible** except where called out in the changelog.
6
+
7
+ ### Changed
8
+
9
+ - **CLI default:** `ollama_agent` with no subcommand runs **`ask`** with the **interactive TUI** when no query is given (same as `ollama_agent ask` with no query). Use **`ask -i` without `--tui`** for the plain line REPL, **`ask "…"`** for a one-shot task, or another subcommand (`self_review`, `sessions`, …) for other workflows.
10
+
11
+ ### Breaking changes (from 0.3.x)
12
+
13
+ - **`tty-spinner` removed** from the gem; **`OllamaAgent::TUI`** no longer exposes `with_spinner`, `dismiss_progress_spinner`, `pause_progress_for_user_prompt`, or `resume_progress_after_user_prompt`. Code that called those methods or relied on the transitive `tty-spinner` dependency must be updated.
14
+ - **`Agent#chat_assistant_message`** uses the streaming HTTP path **only** when something subscribes to **`on_token`** (not merely **`on_thinking`**). Integrations that registered only `on_thinking` expecting streamed chat must also subscribe to `on_token` or use another supported hook.
15
+
16
+ ### Fixed
17
+
18
+ - TUI slash-command tab completion no longer raises `FrozenError` when editing a completed frozen candidate (e.g. `/help`).
19
+
3
20
  ## [0.3.0] - 2026-04-06
4
21
 
5
22
  ### Added
23
+
6
24
  - `ToolRuntime` — JSON plan loop for custom tools (`OllamaJsonPlanner`, registry, executor); see `docs/TOOL_RUNTIME.md`
7
25
  - Optional **ruby_mastery** context for `self_review` / `improve` (`OLLAMA_AGENT_RUBY_MASTERY`, `--no-ruby-mastery`)
8
26
  - `OllamaAgent::ModelEnv` — shared model name resolution from environment
@@ -11,14 +29,17 @@
11
29
  - External agents / argv expansion and related orchestration refinements
12
30
 
13
31
  ### Changed
32
+
14
33
  - `SearchBackend` finds `rg` / `grep` by scanning `PATH` (avoids relying on a `command` executable on trimmed `PATH`)
15
34
 
16
35
  ### Fixed
36
+
17
37
  - `SelfImprovement::Improver#run` accepts `max_tokens` and `context_summarize` from the CLI (Ruby 3 keyword compatibility)
18
38
 
19
39
  ## [0.2.0] - 2026-03-26
20
40
 
21
41
  ### Added
42
+
22
43
  - `write_file` tool — create or overwrite files (complements `edit_file` for surgical diffs)
23
44
  - `OllamaAgent::Tools.register` — extensible tool registry for library consumers
24
45
  - `Streaming::Hooks` — event bus (`on_token`, `on_tool_call`, `on_tool_result`, `on_complete`, `on_error`, `on_retry`)
@@ -32,10 +53,12 @@
32
53
  - `docs/ARCHITECTURE.md`, `docs/TOOLS.md`, `docs/SESSIONS.md`
33
54
 
34
55
  ### Changed
56
+
35
57
  - `READ_ONLY_TOOLS` now excludes both `edit_file` and `write_file`
36
58
  - `Agent` now exposes `#hooks` (`Streaming::Hooks`) and `#session_id`
37
59
 
38
60
  ### New environment variables
61
+
39
62
  - `OLLAMA_AGENT_STREAM`, `OLLAMA_AGENT_MAX_TOKENS`
40
63
  - `OLLAMA_AGENT_MAX_RETRIES`, `OLLAMA_AGENT_RETRY_BASE_DELAY`
41
64
  - `OLLAMA_AGENT_AUDIT`, `OLLAMA_AGENT_AUDIT_LOG_PATH`
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ollama_agent
2
2
 
3
- Version: 0.1.0
3
+ Version: 1.0.0
4
4
 
5
5
  Ruby gem that runs a **CLI coding agent** against a local [Ollama](https://ollama.com) model. It exposes tools to **list files**, **read files**, **search the tree** (ripgrep or grep), and **apply unified diffs** so the model can make small, reviewable edits.
6
6
 
@@ -40,6 +40,16 @@ bundle install
40
40
 
41
41
  ## Usage
42
42
 
43
+ **Default:** run the gem with **no subcommand** to open the **interactive TUI** (same as `ask` with no query):
44
+
45
+ ```bash
46
+ ollama_agent
47
+ # or from this repo:
48
+ bundle exec ruby exe/ollama_agent
49
+ ```
50
+
51
+ Other entry points are **opt-in**: pass a **subcommand** (`self_review`, `sessions`, …) or **`ask` / `orchestrate`** with a **query** for a one-shot task, or flags for a plain line REPL (see below).
52
+
43
53
  From the project you want the agent to modify (set the working directory accordingly):
44
54
 
45
55
  ```bash
@@ -63,10 +73,11 @@ Long-running models (slow local inference):
63
73
  bundle exec ruby exe/ollama_agent ask --timeout 300 "Your task"
64
74
  ```
65
75
 
66
- Interactive REPL:
76
+ **Plain line REPL** (no TUI boxes / markdown shell): use **`ask` (or `orchestrate`) with `-i` and without `--tui`**—for example when you omit the query you must opt out of the default TUI this way:
67
77
 
68
78
  ```bash
69
79
  bundle exec ruby exe/ollama_agent ask --interactive
80
+ # same idea: explicit -i, no --tui
70
81
  ```
71
82
 
72
83
  Self-review modes (default project root is the **current working directory** unless you set `--root` or `OLLAMA_AGENT_ROOT`):
@@ -271,7 +282,7 @@ Repository **secrets** (Settings → Secrets and variables → Actions):
271
282
  Release steps:
272
283
 
273
284
  1. Bump `OllamaAgent::VERSION` in `lib/ollama_agent/version.rb` and commit to `main`.
274
- 2. Tag: `git tag v0.1.0` (must match the version string) and `git push origin v0.1.0`.
285
+ 2. Tag: `git tag v1.0.0` (must match the version string) and `git push origin v1.0.0`.
275
286
 
276
287
  ## License
277
288
 
@@ -7,7 +7,10 @@ module OllamaAgent
7
7
  attr_reader :model, :root, :confirm_patches, :http_timeout, :think, :read_only, :patch_policy,
8
8
  :skill_paths, :skills_enabled, :skills_include, :skills_exclude, :external_skills_enabled,
9
9
  :orchestrator, :confirm_delegation, :max_retries, :audit, :session_id, :resume,
10
- :max_tokens, :context_summarize, :stdin, :stdout
10
+ :max_tokens, :context_summarize, :stdin, :stdout, :user_prompt,
11
+ # v2 platform options
12
+ :provider, :provider_name, :budget, :permissions, :policies,
13
+ :memory_manager, :trace_logger, :approval_gate
11
14
 
12
15
  # @param confirm_delegation [Boolean, nil] nil means default true
13
16
  # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists, Metrics/AbcSize -- value object mirrors Agent keywords
@@ -19,7 +22,11 @@ module OllamaAgent
19
22
  max_retries: nil, audit: nil,
20
23
  session_id: nil, resume: false,
21
24
  max_tokens: nil, context_summarize: nil,
22
- stdin: $stdin, stdout: $stdout)
25
+ stdin: $stdin, stdout: $stdout, user_prompt: nil,
26
+ # v2 platform options (all optional — nil keeps existing behaviour)
27
+ provider: nil, provider_name: nil, budget: nil,
28
+ permissions: nil, policies: nil,
29
+ memory_manager: nil, trace_logger: nil, approval_gate: nil)
23
30
  @model = model
24
31
  @root = root
25
32
  @confirm_patches = confirm_patches
@@ -42,6 +49,16 @@ module OllamaAgent
42
49
  @context_summarize = context_summarize
43
50
  @stdin = stdin
44
51
  @stdout = stdout
52
+ @user_prompt = user_prompt
53
+ # v2 platform options
54
+ @provider = provider
55
+ @provider_name = provider_name
56
+ @budget = budget
57
+ @permissions = permissions
58
+ @policies = policies
59
+ @memory_manager = memory_manager
60
+ @trace_logger = trace_logger
61
+ @approval_gate = approval_gate
45
62
  end
46
63
  # rubocop:enable Metrics/MethodLength, Metrics/ParameterLists, Metrics/AbcSize
47
64
 
@@ -6,21 +6,16 @@ module OllamaAgent
6
6
  module ClientWiring
7
7
  private
8
8
 
9
- # rubocop:disable Metrics/MethodLength
10
9
  def build_default_client
11
- config = Ollama::Config.new
12
10
  @http_timeout_seconds = resolved_http_timeout_seconds
13
- config.timeout = @http_timeout_seconds
14
- OllamaConnection.apply_env_to_config(config)
15
- ollama_client = Ollama::Client.new(config: config)
16
- Resilience::RetryMiddleware.new(
17
- client: ollama_client,
11
+ OllamaConnection.retry_wrapped_client(
12
+ timeout: @http_timeout_seconds,
18
13
  max_attempts: resolved_max_retries,
14
+ base_url: nil,
19
15
  hooks: @hooks,
20
16
  base_delay: resolved_retry_base_delay
21
17
  )
22
18
  end
23
- # rubocop:enable Metrics/MethodLength
24
19
 
25
20
  def resolved_max_retries
26
21
  return @max_retries unless @max_retries.nil?
@@ -24,14 +24,48 @@ module OllamaAgent
24
24
 
25
25
  def append_tool_results(messages, tool_calls)
26
26
  tool_calls.each do |tool_call|
27
- @hooks.emit(:on_tool_call, { name: tool_call.name, args: tool_call.arguments || {}, turn: current_turn })
28
- result = execute_tool(tool_call.name, tool_call.arguments || {})
29
- @hooks.emit(:on_tool_result, { name: tool_call.name, result: result.to_s, turn: current_turn })
27
+ name = tool_call.name
28
+ args = tool_call.arguments || {}
29
+
30
+ @hooks.emit(:on_tool_call, { name: name, args: args, turn: current_turn })
31
+ @loop_detector&.record!(name, args)
32
+
33
+ result = platform_guarded_tool_call(name, args)
34
+
35
+ @hooks.emit(:on_tool_result, { name: name, result: result.to_s, turn: current_turn })
36
+ @memory_manager&.record_tool_call(name, args, result)
30
37
  messages << tool_message(tool_call, result)
31
38
  save_message_to_session(messages.last)
32
39
  end
33
40
  end
34
41
 
42
+ # Run permission / policy guards before delegating to execute_tool.
43
+ def platform_guarded_tool_call(name, args)
44
+ ctx = build_tool_context
45
+
46
+ # Permission check
47
+ if @permissions && !@permissions.allowed?(name)
48
+ return "Permission denied: tool '#{name}' is not allowed under the current permission profile (#{@permissions.profile})."
49
+ end
50
+
51
+ # Policy check
52
+ if @policies
53
+ rejection = @policies.evaluate(name, args, ctx)
54
+ return rejection if rejection
55
+ end
56
+
57
+ execute_tool(name, args)
58
+ end
59
+
60
+ def build_tool_context
61
+ {
62
+ root: @root,
63
+ read_only: @read_only,
64
+ memory_manager: @memory_manager,
65
+ shell_call_count: @shell_call_count || 0
66
+ }
67
+ end
68
+
35
69
  def save_message_to_session(msg)
36
70
  return unless @session_id
37
71
 
@@ -16,10 +16,14 @@ require_relative "context/manager"
16
16
  require_relative "session/store"
17
17
  require_relative "env_config"
18
18
  require_relative "model_env"
19
+ require_relative "ollama_cloud_catalog"
19
20
  require_relative "agent/agent_config"
20
21
  require_relative "agent/client_wiring"
21
22
  require_relative "agent/prompt_wiring"
22
23
  require_relative "agent/session_wiring"
24
+ require_relative "core/budget"
25
+ require_relative "core/loop_detector"
26
+ require_relative "core/trace_logger"
23
27
 
24
28
  module OllamaAgent
25
29
  # Runs a tool-calling loop against Ollama: read files, search, apply unified diffs.
@@ -34,7 +38,7 @@ module OllamaAgent
34
38
  MAX_TURNS = 64
35
39
  DEFAULT_HTTP_TIMEOUT = 120
36
40
 
37
- attr_reader :client, :root, :hooks
41
+ attr_reader :client, :root, :hooks, :model
38
42
 
39
43
  # @param config [AgentConfig, nil] when set, keyword options are ignored (use {Runner} or build {AgentConfig}).
40
44
  # rubocop:disable Metrics/ParameterLists
@@ -48,7 +52,10 @@ module OllamaAgent
48
52
  max_retries: nil, audit: nil,
49
53
  session_id: nil, resume: false,
50
54
  max_tokens: nil, context_summarize: nil,
51
- stdin: $stdin, stdout: $stdout)
55
+ stdin: $stdin, stdout: $stdout,
56
+ provider: nil, provider_name: nil, budget: nil,
57
+ permissions: nil, policies: nil,
58
+ memory_manager: nil, trace_logger: nil, approval_gate: nil, user_prompt: nil)
52
59
  cfg = config || AgentConfig.new(
53
60
  model: model, root: root, confirm_patches: confirm_patches, http_timeout: http_timeout, think: think,
54
61
  read_only: read_only, patch_policy: patch_policy,
@@ -56,10 +63,14 @@ module OllamaAgent
56
63
  skills_exclude: skills_exclude, external_skills_enabled: external_skills_enabled,
57
64
  orchestrator: orchestrator, confirm_delegation: confirm_delegation,
58
65
  max_retries: max_retries, audit: audit, session_id: session_id, resume: resume,
59
- max_tokens: max_tokens, context_summarize: context_summarize, stdin: stdin, stdout: stdout
66
+ max_tokens: max_tokens, context_summarize: context_summarize, stdin: stdin, stdout: stdout,
67
+ provider: provider, provider_name: provider_name, budget: budget,
68
+ permissions: permissions, policies: policies,
69
+ memory_manager: memory_manager, trace_logger: trace_logger, approval_gate: approval_gate,
70
+ user_prompt: user_prompt
60
71
  )
61
72
  apply_agent_config(cfg)
62
- @user_prompt = UserPrompt.new(stdin: cfg.stdin, stdout: cfg.stdout)
73
+ @user_prompt = cfg.user_prompt || UserPrompt.new(stdin: cfg.stdin, stdout: cfg.stdout)
63
74
  @context_manager = Context::Manager.new(max_tokens: @max_tokens, context_summarize: @context_summarize)
64
75
  @hooks = Streaming::Hooks.new
65
76
  attach_audit_logger if resolved_audit_enabled
@@ -74,6 +85,38 @@ module OllamaAgent
74
85
  execute_agent_turns(messages)
75
86
  end
76
87
 
88
+ # Switch the chat model for subsequent {#run} calls (same session, same client).
89
+ # Accepts Ollama local tags (e.g. +llama3.2+) or cloud catalog names (e.g. +glm-5.1+).
90
+ #
91
+ # @param name [String]
92
+ # @return [String] the normalized model id
93
+ # @raise [OllamaAgent::Error] when +name+ is blank
94
+ def assign_chat_model!(name)
95
+ n = name.to_s.strip
96
+ raise Error, "Model name cannot be empty" if n.empty?
97
+
98
+ @model = n
99
+ n
100
+ end
101
+
102
+ # Names from the local Ollama daemon (+/api/tags+ on your +base_url+). Not used by the REPL +/models+ command.
103
+ #
104
+ # @return [Array<String>]
105
+ def list_local_model_names
106
+ return [] unless @client.respond_to?(:list_model_names)
107
+
108
+ @client.list_model_names
109
+ rescue StandardError
110
+ []
111
+ end
112
+
113
+ # Cloud catalog from +https://ollama.com/api/tags+ (see {OllamaCloudCatalog}). REPL +/models+ uses this only.
114
+ #
115
+ # @return [Array<String>]
116
+ def list_cloud_model_names
117
+ OllamaCloudCatalog.list_model_names
118
+ end
119
+
77
120
  private
78
121
 
79
122
  # rubocop:disable Metrics/MethodLength, Metrics/AbcSize -- maps AgentConfig to ivars + resolved max turns
@@ -100,20 +143,50 @@ module OllamaAgent
100
143
  @context_summarize = cfg.context_summarize
101
144
  strict = EnvConfig.strict_env?
102
145
  @max_turns = EnvConfig.fetch_int("OLLAMA_AGENT_MAX_TURNS", MAX_TURNS, strict: strict)
146
+ # v2 platform subsystems (all optional; nil keeps legacy behaviour)
147
+ @budget = cfg.budget || Core::Budget.new(max_steps: @max_turns, max_tokens: @max_tokens)
148
+ @loop_detector = Core::LoopDetector.new
149
+ @trace_logger = cfg.trace_logger
150
+ @memory_manager = cfg.memory_manager
151
+ @permissions = cfg.permissions
152
+ @policies = cfg.policies
153
+ @approval_gate = cfg.approval_gate
154
+ @provider = cfg.provider
155
+ @provider_name = cfg.provider_name
103
156
  end
104
157
  # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
105
158
 
106
159
  # rubocop:disable Metrics/MethodLength -- turn loop with early break
107
160
  def execute_agent_turns(messages)
108
161
  @current_turn = 0
162
+ @budget.reset!
163
+ @loop_detector.reset!
164
+ @trace_logger&.start_run(query: messages.last&.fetch(:content, nil))
165
+
109
166
  @max_turns.times do
110
167
  @current_turn += 1
111
- trimmed = trimmed_messages_for_chat(messages)
112
- message = chat_assistant_message(trimmed)
168
+ @budget.record_step!
169
+
170
+ if @budget.exceeded?
171
+ reason = @budget.exceeded_reason
172
+ warn "ollama_agent: budget exceeded — #{reason}"
173
+ @trace_logger&.budget_exceeded(reason: reason)
174
+ break
175
+ end
176
+
177
+ trimmed = trimmed_messages_for_chat(messages)
178
+ message = chat_assistant_message(trimmed)
113
179
  tool_calls = tool_calls_from(message)
114
180
  persist_assistant_turn(messages, message)
115
181
  break if tool_calls.empty?
116
182
 
183
+ if @loop_detector.loop_detected?
184
+ summary = @loop_detector.loop_summary
185
+ warn "ollama_agent: #{summary}"
186
+ @trace_logger&.loop_detected(summary: summary)
187
+ break
188
+ end
189
+
117
190
  append_tool_results(messages, tool_calls)
118
191
  end
119
192
  emit_turn_complete(messages)
@@ -206,6 +279,9 @@ module OllamaAgent
206
279
  end
207
280
 
208
281
  def announce_assistant_content(message)
282
+ @hooks.emit(:on_assistant_message, { message: message })
283
+ return if @hooks.subscribed?(:on_assistant_message)
284
+
209
285
  Console.puts_assistant_message(message)
210
286
  end
211
287
 
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ require_relative "repl_shared"
6
+
7
+ module OllamaAgent
8
+ class CLI
9
+ # Interactive REPL for the agent.
10
+ #
11
+ # Features:
12
+ # - Slash commands (/help, /model, /models, /session, /memory, /status, /clear, /config, /provider, /index)
13
+ # - Readline history with persistent file
14
+ # - Multi-line input (end with blank line)
15
+ # - Session resume inside REPL
16
+ # - Token budget and loop-detector status display
17
+ # - Graceful Ctrl-C and Ctrl-D handling
18
+ # rubocop:disable Metrics/ClassLength -- readline wiring + banner + history
19
+ class Repl
20
+ include ReplShared
21
+
22
+ HISTORY_FILE = File.join(Dir.home, ".config", "ollama_agent", "repl_history")
23
+ MAX_HISTORY = 500
24
+
25
+ PROMPT = "\e[32mollama\e[0m \e[90m›\e[0m "
26
+
27
+ def initialize(agent:, memory: nil, budget: nil, stdout: $stdout, stderr: $stderr)
28
+ @agent = agent
29
+ @memory = memory
30
+ @budget = budget
31
+ @stdout = stdout
32
+ @stderr = stderr
33
+ @running = false
34
+ end
35
+
36
+ # rubocop:disable Metrics/MethodLength -- readline loop + history ensure
37
+ def start
38
+ @running = true
39
+ setup_readline
40
+ print_banner
41
+
42
+ loop do
43
+ input = read_line
44
+ break if input.nil?
45
+
46
+ line = input.chomp.strip
47
+ next if line.empty?
48
+
49
+ break if %w[/exit exit].include?(line)
50
+
51
+ if line.start_with?("/")
52
+ handle_slash(line)
53
+ else
54
+ run_query(line)
55
+ end
56
+ end
57
+
58
+ @stdout.puts "\n\e[90mGoodbye.\e[0m"
59
+ ensure
60
+ save_history
61
+ end
62
+ # rubocop:enable Metrics/MethodLength
63
+
64
+ private
65
+
66
+ def setup_readline
67
+ return unless readline_available?
68
+
69
+ load_history
70
+ Readline.completion_proc = slash_completer
71
+ configure_readline_slash_completion
72
+ end
73
+
74
+ # Shows command hints: type `/` then Tab (lists matches) or Tab again to cycle.
75
+ # With GNU Readline, `autocompletion` can show inline candidates while typing.
76
+ def configure_readline_slash_completion
77
+ Readline.completion_append_character = "" if Readline.respond_to?(:completion_append_character=)
78
+ Readline.autocompletion = true if Readline.respond_to?(:autocompletion=)
79
+ end
80
+
81
+ def slash_completer
82
+ proc do |input|
83
+ slash_completer_candidates.select { |cmd| cmd.start_with?(input.to_s) }
84
+ end
85
+ end
86
+
87
+ def read_line
88
+ if readline_available?
89
+ Readline.readline(PROMPT, true)
90
+ else
91
+ @stdout.print PROMPT
92
+ @stdout.flush
93
+ $stdin.gets
94
+ end
95
+ rescue Interrupt
96
+ @stdout.puts ""
97
+ ""
98
+ end
99
+
100
+ def readline_available?
101
+ return @readline_available unless @readline_available.nil?
102
+
103
+ @readline_available = (require "readline") && true
104
+ rescue LoadError
105
+ @readline_available = false
106
+ end
107
+
108
+ def run_query(query)
109
+ @agent.run(query)
110
+ rescue OllamaAgent::Error => e
111
+ @stderr.puts "\e[31mError: #{e.message}\e[0m"
112
+ rescue StandardError => e
113
+ @stderr.puts "\e[31m#{e.class}: #{e.message}\e[0m"
114
+ @stderr.puts e.backtrace.first(5).join("\n") if ENV["OLLAMA_AGENT_DEBUG"] == "1"
115
+ end
116
+
117
+ # rubocop:disable Metrics/MethodLength -- ASCII banner lines
118
+ def print_banner
119
+ @stdout.puts "\e[1m\e[34m"
120
+ @stdout.puts " ██████╗ ██╗ ██╗ █████╗ ███╗ ███╗ █████╗ "
121
+ @stdout.puts " ██╔═══██╗██║ ██║ ██╔══██╗████╗ ████║██╔══██╗"
122
+ @stdout.puts " ██║ ██║██║ ██║ ███████║██╔████╔██║███████║"
123
+ @stdout.puts " ██║ ██║██║ ██║ ██╔══██║██║╚██╔╝██║██╔══██║"
124
+ @stdout.puts " ╚██████╔╝███████╗███████╗██║ ██║██║ ╚═╝ ██║██║ ██║"
125
+ @stdout.puts " ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝"
126
+ @stdout.puts "\e[0m"
127
+ @stdout.puts " Universal AI operator runtime • type \e[33m/help\e[0m for commands"
128
+ print_slash_completion_hint if readline_available?
129
+ @stdout.puts ""
130
+ end
131
+ # rubocop:enable Metrics/MethodLength
132
+
133
+ def print_slash_completion_hint
134
+ @stdout.puts " \e[33m/\e[0m then \e[33mTab\e[0m — slash-command hints; Tab again to list all matches"
135
+ end
136
+
137
+ def load_history
138
+ return unless File.exist?(HISTORY_FILE)
139
+
140
+ File.readlines(HISTORY_FILE, chomp: true).last(MAX_HISTORY).each do |line|
141
+ Readline::HISTORY.push(line)
142
+ end
143
+ rescue StandardError
144
+ nil
145
+ end
146
+
147
+ def save_history
148
+ return unless readline_available?
149
+
150
+ dir = File.dirname(HISTORY_FILE)
151
+ FileUtils.mkdir_p(dir)
152
+ File.write(HISTORY_FILE, Readline::HISTORY.to_a.last(MAX_HISTORY).join("\n"))
153
+ rescue StandardError
154
+ nil
155
+ end
156
+ end
157
+ # rubocop:enable Metrics/ClassLength
158
+ end
159
+ end