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,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ module Indexing
5
+ # Parses and summarizes unified diffs.
6
+ # Provides human-readable change summaries and statistics without
7
+ # needing the full file contents.
8
+ class DiffSummarizer
9
+ FileDiff = Data.define(:path, :additions, :deletions, :hunks, :is_new, :is_deleted, :is_rename)
10
+
11
+ # Parse a unified diff string and return structured FileDiff objects.
12
+ # @param diff [String] unified diff content
13
+ # @return [Array<FileDiff>]
14
+ def self.parse(diff)
15
+ new.parse(diff)
16
+ end
17
+
18
+ # Return a short human-readable summary of a diff.
19
+ # @param diff [String]
20
+ # @return [String]
21
+ def self.summarize(diff)
22
+ new.summarize(diff)
23
+ end
24
+
25
+ def parse(diff)
26
+ return [] if diff.nil? || diff.strip.empty?
27
+
28
+ file_diffs = []
29
+ current = nil
30
+
31
+ diff.each_line do |line|
32
+ line = line.rstrip
33
+
34
+ if line.start_with?("diff --git ")
35
+ file_diffs << current if current
36
+ current = new_file_diff(line)
37
+
38
+ elsif line.start_with?("new file mode")
39
+ current[:is_new] = true if current
40
+
41
+ elsif line.start_with?("deleted file mode")
42
+ current[:is_deleted] = true if current
43
+
44
+ elsif line.start_with?("rename to ")
45
+ current[:is_rename] = true if current
46
+
47
+ elsif line.start_with?("--- ") || line.start_with?("+++ ")
48
+ next # skip --- +++ headers
49
+
50
+ elsif line.start_with?("@@")
51
+ current[:hunks] += 1 if current
52
+
53
+ elsif line.start_with?("+") && !line.start_with?("+++")
54
+ current[:additions] += 1 if current
55
+
56
+ elsif line.start_with?("-") && !line.start_with?("---")
57
+ current[:deletions] += 1 if current
58
+ end
59
+ end
60
+
61
+ file_diffs << current if current
62
+ file_diffs.compact.map { |d| build_entry(d) }
63
+ end
64
+
65
+ # Human-readable multi-line summary.
66
+ def summarize(diff)
67
+ parsed = parse(diff)
68
+ return "Empty diff" if parsed.empty?
69
+
70
+ total_add = parsed.sum(&:additions)
71
+ total_del = parsed.sum(&:deletions)
72
+ header = "#{parsed.size} file(s) changed: +#{total_add} -#{total_del}"
73
+
74
+ lines = [header]
75
+ parsed.each do |fd|
76
+ tag = if fd.is_new then "[new]"
77
+ elsif fd.is_deleted then "[deleted]"
78
+ elsif fd.is_rename then "[renamed]"
79
+ else ""
80
+ end
81
+
82
+ lines << " #{fd.path} #{tag} +#{fd.additions} -#{fd.deletions}"
83
+ end
84
+
85
+ lines.join("\n")
86
+ end
87
+
88
+ # Brief one-liner for embedding in prompts.
89
+ def one_liner(diff)
90
+ parsed = parse(diff)
91
+ return "empty diff" if parsed.empty?
92
+
93
+ paths = parsed.map(&:path).first(3).join(", ")
94
+ suffix = parsed.size > 3 ? " (+#{parsed.size - 3} more)" : ""
95
+ total_a = parsed.sum(&:additions)
96
+ total_d = parsed.sum(&:deletions)
97
+
98
+ "#{paths}#{suffix} [+#{total_a}/-#{total_d}]"
99
+ end
100
+
101
+ private
102
+
103
+ def new_file_diff(header_line)
104
+ # "diff --git a/path/to/file b/path/to/file"
105
+ match = header_line.match(%r{diff --git a/(.+) b/})
106
+ path = match ? match[1] : header_line.split.last
107
+
108
+ { path: path, additions: 0, deletions: 0, hunks: 0,
109
+ is_new: false, is_deleted: false, is_rename: false }
110
+ end
111
+
112
+ def build_entry(d)
113
+ FileDiff.new(
114
+ path: d[:path],
115
+ additions: d[:additions],
116
+ deletions: d[:deletions],
117
+ hunks: d[:hunks],
118
+ is_new: d[:is_new],
119
+ is_deleted: d[:is_deleted],
120
+ is_rename: d[:is_rename]
121
+ )
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "repo_scanner"
4
+
5
+ module OllamaAgent
6
+ module Indexing
7
+ # Builds a searchable in-memory index of files in the repository.
8
+ # Extracts: file paths, function/class names (Ruby), and word tokens.
9
+ # Used by ContextPacker to score files for relevance to a query.
10
+ class FileIndexer
11
+ IndexEntry = Data.define(:relative_path, :language, :symbols, :tokens, :size)
12
+
13
+ MAX_FILE_BYTES = 512_000 # 500 KB — skip larger files for indexing
14
+
15
+ def initialize(root:, scanner: nil)
16
+ @root = File.expand_path(root)
17
+ @scanner = scanner || RepoScanner.new(root: @root)
18
+ @index = nil
19
+ end
20
+
21
+ # Build or return the cached index.
22
+ # @param force [Boolean] rebuild even if cached
23
+ # @return [Array<IndexEntry>]
24
+ def build(force: false)
25
+ return @index if @index && !force
26
+
27
+ @index = @scanner.scan.filter_map { |entry| index_file(entry) }
28
+ end
29
+
30
+ # Search index for entries relevant to a query string.
31
+ # Returns entries sorted by score (highest first).
32
+ # @param query [String]
33
+ # @param top_n [Integer]
34
+ # @param languages [Array<Symbol>, nil]
35
+ def search(query, top_n: 20, languages: nil)
36
+ idx = build
37
+
38
+ keywords = tokenize(query).uniq
39
+ return idx.first(top_n) if keywords.empty?
40
+
41
+ scored = idx.map do |entry|
42
+ next if languages && !languages.include?(entry.language)
43
+
44
+ score = score_entry(entry, keywords)
45
+ [entry, score]
46
+ end.compact
47
+
48
+ scored.sort_by { |_, s| -s }
49
+ .first(top_n)
50
+ .map(&:first)
51
+ end
52
+
53
+ # Invalidate and rebuild the index.
54
+ def refresh!
55
+ build(force: true)
56
+ end
57
+
58
+ def indexed_count
59
+ build.size
60
+ end
61
+
62
+ private
63
+
64
+ def index_file(file_entry)
65
+ return nil if file_entry.size > MAX_FILE_BYTES
66
+
67
+ content = File.read(file_entry.path, encoding: "utf-8", invalid: :replace)
68
+ symbols = extract_symbols(content, file_entry.language)
69
+ tokens = tokenize(content + " " + file_entry.relative_path)
70
+
71
+ IndexEntry.new(
72
+ relative_path: file_entry.relative_path,
73
+ language: file_entry.language,
74
+ symbols: symbols,
75
+ tokens: tokens,
76
+ size: file_entry.size
77
+ )
78
+ rescue StandardError
79
+ nil
80
+ end
81
+
82
+ def extract_symbols(content, language)
83
+ case language
84
+ when :ruby then extract_ruby_symbols(content)
85
+ when :javascript,
86
+ :typescript then extract_js_symbols(content)
87
+ when :python then extract_python_symbols(content)
88
+ else []
89
+ end
90
+ end
91
+
92
+ def extract_ruby_symbols(content)
93
+ symbols = []
94
+ content.scan(/^\s*(?:def|class|module|attr_\w+)\s+(\w+)/) { |m| symbols << m[0] }
95
+ symbols
96
+ end
97
+
98
+ def extract_js_symbols(content)
99
+ symbols = []
100
+ content.scan(/(?:function\s+|class\s+|const\s+|let\s+|var\s+)(\w+)/) { |m| symbols << m[0] }
101
+ symbols
102
+ end
103
+
104
+ def extract_python_symbols(content)
105
+ symbols = []
106
+ content.scan(/^(?:def|class)\s+(\w+)/) { |m| symbols << m[0] }
107
+ symbols
108
+ end
109
+
110
+ def tokenize(text)
111
+ text.downcase
112
+ .scan(/[a-z][a-z0-9_]{2,}/)
113
+ .uniq
114
+ end
115
+
116
+ def score_entry(entry, keywords)
117
+ path_tokens = tokenize(entry.relative_path)
118
+ sym_tokens = entry.symbols.map(&:downcase)
119
+
120
+ keywords.sum do |kw|
121
+ path_match = path_tokens.any? { |t| t.include?(kw) } ? 3 : 0
122
+ symbol_match = sym_tokens.any? { |s| s.include?(kw) } ? 2 : 0
123
+ token_match = entry.tokens.any? { |t| t.include?(kw) } ? 1 : 0
124
+ path_match + symbol_match + token_match
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "find"
4
+
5
+ module OllamaAgent
6
+ module Indexing
7
+ # Scans a repository and returns a file inventory with language tags.
8
+ # Language detection is extension-based (no external gems required).
9
+ # Used by ContextPacker to select relevant files for the agent context.
10
+ class RepoScanner
11
+ LANGUAGE_EXTENSIONS = {
12
+ ruby: %w[.rb .rake .gemspec],
13
+ javascript: %w[.js .jsx .mjs .cjs],
14
+ typescript: %w[.ts .tsx],
15
+ python: %w[.py .pyw],
16
+ go: %w[.go],
17
+ rust: %w[.rs],
18
+ java: %w[.java],
19
+ kotlin: %w[.kt .kts],
20
+ swift: %w[.swift],
21
+ cpp: %w[.cpp .cc .cxx .hpp .hh .h],
22
+ c: %w[.c .h],
23
+ csharp: %w[.cs],
24
+ php: %w[.php],
25
+ elixir: %w[.ex .exs],
26
+ erlang: %w[.erl .hrl],
27
+ haskell: %w[.hs .lhs],
28
+ scala: %w[.scala],
29
+ clojure: %w[.clj .cljs .cljc],
30
+ shell: %w[.sh .bash .zsh .fish],
31
+ yaml: %w[.yml .yaml],
32
+ json: %w[.json .jsonc],
33
+ toml: %w[.toml],
34
+ markdown: %w[.md .mdx .markdown],
35
+ html: %w[.html .htm .xhtml],
36
+ css: %w[.css .scss .sass .less],
37
+ sql: %w[.sql],
38
+ dockerfile: %w[Dockerfile],
39
+ terraform: %w[.tf .tfvars],
40
+ proto: %w[.proto]
41
+ }.freeze
42
+
43
+ IGNORED_DIRS = %w[
44
+ .git .svn .hg .bzr
45
+ node_modules vendor .bundle
46
+ tmp log coverage .nyc_output dist build out target
47
+ __pycache__ .pytest_cache .mypy_cache .tox venv env .venv
48
+ .ollama_agent .idea .vscode .cursor
49
+ ].freeze
50
+
51
+ IGNORED_FILES = %w[
52
+ Gemfile.lock yarn.lock package-lock.json pnpm-lock.yaml
53
+ .DS_Store Thumbs.db *.min.js *.min.css
54
+ ].freeze
55
+
56
+ FileEntry = Data.define(:path, :relative_path, :language, :size, :modified_at)
57
+
58
+ def initialize(root:, exclude_dirs: nil, max_file_size: 1_048_576)
59
+ @root = File.expand_path(root)
60
+ @exclude_dirs = (exclude_dirs || []) + IGNORED_DIRS
61
+ @max_file_size = max_file_size
62
+ @ext_map = build_ext_map
63
+ end
64
+
65
+ # Scan the repository and return FileEntry objects.
66
+ # @param languages [Array<Symbol>, nil] filter to specific languages
67
+ # @return [Array<FileEntry>]
68
+ def scan(languages: nil)
69
+ results = []
70
+
71
+ Find.find(@root) do |path|
72
+ basename = File.basename(path)
73
+
74
+ if File.directory?(path)
75
+ Find.prune if prune_dir?(path, basename)
76
+ next
77
+ end
78
+
79
+ next unless File.file?(path)
80
+ next if ignored_file?(basename)
81
+
82
+ size = File.size(path)
83
+ next if size > @max_file_size
84
+
85
+ lang = detect_language(path)
86
+ next if languages && !languages.map(&:to_sym).include?(lang)
87
+
88
+ rel = path.sub("#{@root}/", "")
89
+ results << FileEntry.new(
90
+ path: path,
91
+ relative_path: rel,
92
+ language: lang,
93
+ size: size,
94
+ modified_at: File.mtime(path)
95
+ )
96
+ rescue StandardError
97
+ next
98
+ end
99
+
100
+ results.sort_by(&:relative_path)
101
+ end
102
+
103
+ # Summary statistics about the repository.
104
+ def stats
105
+ files = scan
106
+ by_lang = files.group_by(&:language)
107
+
108
+ {
109
+ total_files: files.size,
110
+ total_bytes: files.sum(&:size),
111
+ root: @root,
112
+ languages: by_lang.transform_values { |fs| { files: fs.size, bytes: fs.sum(&:size) } }
113
+ }
114
+ end
115
+
116
+ # Files most recently modified.
117
+ def recently_modified(n: 20)
118
+ scan.max_by(n, &:modified_at)
119
+ end
120
+
121
+ private
122
+
123
+ def build_ext_map
124
+ map = {}
125
+ LANGUAGE_EXTENSIONS.each do |lang, exts|
126
+ exts.each { |ext| map[ext.downcase] = lang }
127
+ end
128
+ map
129
+ end
130
+
131
+ def detect_language(path)
132
+ base = File.basename(path)
133
+ return :dockerfile if base == "Dockerfile"
134
+ return :ruby if base.match?(/\A(Rakefile|Gemfile|Guardfile|Capfile|Brewfile)\z/)
135
+
136
+ ext = File.extname(path).downcase
137
+ @ext_map[ext] || :other
138
+ end
139
+
140
+ def prune_dir?(path, basename)
141
+ return true if basename.start_with?(".")
142
+ return true if @exclude_dirs.include?(basename)
143
+
144
+ @exclude_dirs.any? { |d| path.include?("/#{d}/") || path.end_with?("/#{d}") }
145
+ end
146
+
147
+ def ignored_file?(basename)
148
+ IGNORED_FILES.any? do |pattern|
149
+ if pattern.include?("*")
150
+ File.fnmatch(pattern, basename)
151
+ else
152
+ basename == pattern
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+ require "time"
6
+
7
+ module OllamaAgent
8
+ module Memory
9
+ # Persistent cross-session, cross-project memory.
10
+ # Stores user preferences, project facts, and reusable summaries.
11
+ # Backed by YAML files in ~/.config/ollama_agent/memory/<namespace>.yml
12
+ class LongTerm
13
+ DEFAULT_BASE = File.join(Dir.home, ".config", "ollama_agent", "memory")
14
+
15
+ Entry = Data.define(:key, :value, :namespace, :created_at, :updated_at)
16
+
17
+ def initialize(base_path: DEFAULT_BASE)
18
+ @base_path = base_path
19
+ end
20
+
21
+ # Store a value under key in namespace.
22
+ # @param key [String]
23
+ # @param value [Object] YAML-serialisable
24
+ # @param namespace [String]
25
+ def store(key, value, namespace: "default")
26
+ data = load_namespace(namespace)
27
+ now = Time.now.iso8601
28
+
29
+ data[key.to_s] = {
30
+ "value" => value,
31
+ "created_at" => data.dig(key.to_s, "created_at") || now,
32
+ "updated_at" => now
33
+ }
34
+
35
+ persist_namespace(namespace, data)
36
+ value
37
+ end
38
+
39
+ # Fetch a value. Returns nil if missing.
40
+ def fetch(key, namespace: "default")
41
+ load_namespace(namespace).dig(key.to_s, "value")
42
+ end
43
+
44
+ # All entries in namespace as {key => value} hash.
45
+ def all(namespace: "default")
46
+ load_namespace(namespace).transform_values { |v| v["value"] }
47
+ end
48
+
49
+ # Delete a key.
50
+ def delete(key, namespace: "default")
51
+ data = load_namespace(namespace)
52
+ removed = data.delete(key.to_s)
53
+ persist_namespace(namespace, data) if removed
54
+ removed
55
+ end
56
+
57
+ # List all keys in a namespace.
58
+ def keys(namespace: "default")
59
+ load_namespace(namespace).keys
60
+ end
61
+
62
+ # Search for entries whose key or value matches a pattern.
63
+ def search(pattern, namespace: "default")
64
+ data = load_namespace(namespace)
65
+ re = Regexp.new(pattern, Regexp::IGNORECASE) rescue nil
66
+ return {} unless re
67
+
68
+ data.select { |k, v| re.match?(k) || re.match?(v["value"].to_s) }
69
+ .transform_values { |v| v["value"] }
70
+ end
71
+
72
+ # All namespace names that have been created.
73
+ def namespaces
74
+ return [] unless File.directory?(@base_path)
75
+
76
+ Dir.glob(File.join(@base_path, "*.yml"))
77
+ .map { |f| File.basename(f, ".yml") }
78
+ end
79
+
80
+ def clear_namespace!(namespace)
81
+ path = namespace_path(namespace)
82
+ File.delete(path) if File.exist?(path)
83
+ end
84
+
85
+ private
86
+
87
+ def namespace_path(namespace)
88
+ safe = namespace.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
89
+ File.join(@base_path, "#{safe}.yml")
90
+ end
91
+
92
+ def load_namespace(namespace)
93
+ path = namespace_path(namespace)
94
+ return {} unless File.exist?(path)
95
+
96
+ YAML.safe_load_file(path) || {}
97
+ rescue StandardError
98
+ {}
99
+ end
100
+
101
+ def persist_namespace(namespace, data)
102
+ FileUtils.mkdir_p(@base_path)
103
+ File.write(namespace_path(namespace), YAML.dump(data))
104
+ rescue StandardError
105
+ nil
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "short_term"
4
+ require_relative "session_memory"
5
+ require_relative "long_term"
6
+
7
+ module OllamaAgent
8
+ module Memory
9
+ # Unified memory interface exposing all three tiers.
10
+ #
11
+ # Tier semantics:
12
+ # :short_term — sliding window of the current run; cleared at run end
13
+ # :session — key-value store for this session; persisted to YAML in project dir
14
+ # :long_term — global persistent store in ~/.config/ollama_agent/memory/
15
+ #
16
+ # @example
17
+ # mem = OllamaAgent::Memory::Manager.new(root: Dir.pwd, session_id: "my-session")
18
+ # mem.record_tool_call("read_file", { path: "lib/agent.rb" }, "content...")
19
+ # mem.remember("project_lang", "Ruby", tier: :long_term)
20
+ # mem.recall("project_lang") # => "Ruby"
21
+ class Manager
22
+ attr_reader :short_term, :session, :long_term
23
+
24
+ def initialize(root:, session_id: nil, long_term_path: nil)
25
+ @short_term = ShortTerm.new
26
+ @session = SessionMemory.new(root: root, session_id: session_id)
27
+ @long_term = LongTerm.new(base_path: long_term_path || LongTerm::DEFAULT_BASE)
28
+ end
29
+
30
+ # ── Short-term recording ─────────────────────────────────────────────
31
+
32
+ def record_tool_call(tool_name, args, result = nil)
33
+ @short_term.record(:tool_call, { tool: tool_name.to_s, args: args })
34
+ @short_term.record(:tool_result, { tool: tool_name.to_s, result: result.to_s[0, 500] }) if result
35
+ end
36
+
37
+ def record_observation(text)
38
+ @short_term.record(:observation, text)
39
+ end
40
+
41
+ def recent_context(n = 10)
42
+ @short_term.recent(n)
43
+ end
44
+
45
+ # ── Durable memory ────────────────────────────────────────────────────
46
+
47
+ # Store a fact at the specified tier.
48
+ # @param key [String]
49
+ # @param value [Object]
50
+ # @param tier [Symbol] :long_term or :session
51
+ # @param namespace [String] only used by long_term
52
+ def remember(key, value, tier: :long_term, namespace: "default")
53
+ case tier.to_sym
54
+ when :long_term then @long_term.store(key.to_s, value, namespace: namespace)
55
+ when :session then @session.set(key.to_s, value)
56
+ else raise ArgumentError, "Unknown memory tier: #{tier}"
57
+ end
58
+ end
59
+
60
+ # Retrieve a fact from the specified tier.
61
+ def recall(key, tier: :long_term, namespace: "default")
62
+ case tier.to_sym
63
+ when :long_term then @long_term.fetch(key.to_s, namespace: namespace)
64
+ when :session then @session.get(key.to_s)
65
+ else nil
66
+ end
67
+ end
68
+
69
+ # Forget a fact from the specified tier.
70
+ def forget(key, tier: :long_term, namespace: "default")
71
+ case tier.to_sym
72
+ when :long_term then @long_term.delete(key.to_s, namespace: namespace)
73
+ when :session then @session.delete(key.to_s)
74
+ end
75
+ end
76
+
77
+ # List all keys in a tier/namespace.
78
+ def list(tier: :long_term, namespace: "default")
79
+ case tier.to_sym
80
+ when :long_term then @long_term.all(namespace: namespace)
81
+ when :session then @session.all
82
+ else {}
83
+ end
84
+ end
85
+
86
+ # Search long-term memory for entries matching a pattern.
87
+ def search(pattern, namespace: "default")
88
+ @long_term.search(pattern, namespace: namespace)
89
+ end
90
+
91
+ # ── Goal tracking ─────────────────────────────────────────────────────
92
+
93
+ def set_goal(description)
94
+ @session.set_goal(description)
95
+ end
96
+
97
+ def complete_goal(description)
98
+ @session.complete_goal(description)
99
+ end
100
+
101
+ def active_goals
102
+ @session.active_goals
103
+ end
104
+
105
+ # ── Lifecycle ─────────────────────────────────────────────────────────
106
+
107
+ # Call at the end of each run to clear short-term memory.
108
+ def flush_short_term!
109
+ @short_term.clear!
110
+ end
111
+
112
+ def summary
113
+ {
114
+ short_term_entries: @short_term.size,
115
+ session_keys: @session.keys.size,
116
+ long_term_namespaces: @long_term.namespaces.size
117
+ }
118
+ end
119
+ end
120
+ end
121
+ end