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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +14 -3
- data/lib/ollama_agent/agent/agent_config.rb +19 -2
- data/lib/ollama_agent/agent/client_wiring.rb +3 -8
- data/lib/ollama_agent/agent/session_wiring.rb +37 -3
- data/lib/ollama_agent/agent.rb +82 -6
- data/lib/ollama_agent/cli/repl.rb +159 -0
- data/lib/ollama_agent/cli/repl_shared.rb +229 -0
- data/lib/ollama_agent/cli/tui_repl.rb +149 -0
- data/lib/ollama_agent/cli.rb +129 -49
- data/lib/ollama_agent/core/action_envelope.rb +82 -0
- data/lib/ollama_agent/core/budget.rb +90 -0
- data/lib/ollama_agent/core/loop_detector.rb +67 -0
- data/lib/ollama_agent/core/schema_validator.rb +136 -0
- data/lib/ollama_agent/core/trace_logger.rb +138 -0
- data/lib/ollama_agent/external_agents/probe.rb +23 -3
- data/lib/ollama_agent/indexing/context_packer.rb +140 -0
- data/lib/ollama_agent/indexing/diff_summarizer.rb +125 -0
- data/lib/ollama_agent/indexing/file_indexer.rb +129 -0
- data/lib/ollama_agent/indexing/repo_scanner.rb +158 -0
- data/lib/ollama_agent/memory/long_term.rb +109 -0
- data/lib/ollama_agent/memory/manager.rb +121 -0
- data/lib/ollama_agent/memory/session_memory.rb +93 -0
- data/lib/ollama_agent/memory/short_term.rb +66 -0
- data/lib/ollama_agent/ollama_cloud_catalog.rb +66 -0
- data/lib/ollama_agent/ollama_connection.rb +30 -0
- data/lib/ollama_agent/plugins/loader.rb +95 -0
- data/lib/ollama_agent/plugins/registry.rb +103 -0
- data/lib/ollama_agent/providers/anthropic.rb +245 -0
- data/lib/ollama_agent/providers/base.rb +79 -0
- data/lib/ollama_agent/providers/ollama.rb +118 -0
- data/lib/ollama_agent/providers/openai.rb +215 -0
- data/lib/ollama_agent/providers/registry.rb +76 -0
- data/lib/ollama_agent/providers/router.rb +93 -0
- data/lib/ollama_agent/resilience/retry_middleware.rb +5 -0
- data/lib/ollama_agent/runner.rb +25 -4
- data/lib/ollama_agent/runtime/approval_gate.rb +74 -0
- data/lib/ollama_agent/runtime/permissions.rb +103 -0
- data/lib/ollama_agent/runtime/policies.rb +100 -0
- data/lib/ollama_agent/runtime/sandbox.rb +130 -0
- data/lib/ollama_agent/streaming/hooks.rb +3 -1
- data/lib/ollama_agent/tools/base.rb +108 -0
- data/lib/ollama_agent/tools/git_tools.rb +176 -0
- data/lib/ollama_agent/tools/http_tools.rb +202 -0
- data/lib/ollama_agent/tools/memory_tools.rb +116 -0
- data/lib/ollama_agent/tools/shell_tools.rb +208 -0
- data/lib/ollama_agent/tui.rb +183 -0
- data/lib/ollama_agent/tui_slash_reader.rb +147 -0
- data/lib/ollama_agent/tui_user_prompt.rb +45 -0
- data/lib/ollama_agent/version.rb +1 -1
- data/lib/ollama_agent.rb +46 -1
- 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
|