llmemory 0.1.17 → 0.2.1
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/README.md +178 -1
- data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +20 -0
- data/lib/llmemory/actions/reason.rb +49 -0
- data/lib/llmemory/actions.rb +8 -0
- data/lib/llmemory/cli/commands/base.rb +8 -0
- data/lib/llmemory/cli/commands/episodic.rb +42 -0
- data/lib/llmemory/cli/commands/forget_log.rb +36 -0
- data/lib/llmemory/cli/commands/procedural.rb +44 -0
- data/lib/llmemory/cli/commands/working.rb +31 -0
- data/lib/llmemory/cli.rb +12 -0
- data/lib/llmemory/configuration.rb +6 -0
- data/lib/llmemory/forget_log.rb +50 -0
- data/lib/llmemory/long_term/episodic/memory.rb +97 -15
- data/lib/llmemory/long_term/episodic/storage.rb +7 -5
- data/lib/llmemory/long_term/episodic/storages/active_record_models.rb +17 -0
- data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +93 -0
- data/lib/llmemory/long_term/episodic/storages/base.rb +5 -0
- data/lib/llmemory/long_term/episodic/storages/database_storage.rb +135 -0
- data/lib/llmemory/long_term/episodic/storages/file_storage.rb +11 -3
- data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +9 -3
- data/lib/llmemory/long_term/file_based/memory.rb +31 -0
- data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +11 -4
- data/lib/llmemory/long_term/file_based/storages/database_storage.rb +16 -6
- data/lib/llmemory/long_term/file_based/storages/file_storage.rb +2 -4
- data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +2 -4
- data/lib/llmemory/long_term/graph_based/memory.rb +95 -51
- data/lib/llmemory/long_term/procedural/memory.rb +170 -0
- data/lib/llmemory/long_term/procedural/skill.rb +93 -0
- data/lib/llmemory/long_term/procedural/storage.rb +33 -0
- data/lib/llmemory/long_term/procedural/storages/active_record_models.rb +17 -0
- data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +104 -0
- data/lib/llmemory/long_term/procedural/storages/base.rb +53 -0
- data/lib/llmemory/long_term/procedural/storages/database_storage.rb +148 -0
- data/lib/llmemory/long_term/procedural/storages/file_storage.rb +135 -0
- data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +79 -0
- data/lib/llmemory/long_term/procedural.rb +12 -0
- data/lib/llmemory/long_term.rb +2 -0
- data/lib/llmemory/mcp/server.rb +13 -1
- data/lib/llmemory/mcp/tools/memory_episode_record.rb +48 -0
- data/lib/llmemory/mcp/tools/memory_episodes.rb +43 -0
- data/lib/llmemory/mcp/tools/memory_forget.rb +53 -0
- data/lib/llmemory/mcp/tools/memory_retrieve.rb +10 -2
- data/lib/llmemory/mcp/tools/memory_skill_register.rb +35 -0
- data/lib/llmemory/mcp/tools/memory_skill_report.rb +35 -0
- data/lib/llmemory/mcp/tools/memory_skills.rb +43 -0
- data/lib/llmemory/memory.rb +34 -1
- data/lib/llmemory/memory_module.rb +55 -0
- data/lib/llmemory/retrieval/bm25_scorer.rb +1 -1
- data/lib/llmemory/retrieval/engine.rb +115 -6
- data/lib/llmemory/retrieval/feedback_store.rb +50 -0
- data/lib/llmemory/retrieval/mmr_reranker.rb +1 -1
- data/lib/llmemory/short_term/checkpoint.rb +2 -14
- data/lib/llmemory/short_term/session_lifecycle.rb +22 -13
- data/lib/llmemory/short_term/stores.rb +27 -0
- data/lib/llmemory/tokenizer.rb +27 -0
- data/lib/llmemory/vector_store/active_record_store.rb +4 -3
- data/lib/llmemory/vector_store.rb +14 -0
- data/lib/llmemory/version.rb +1 -1
- data/lib/llmemory/working_memory.rb +83 -0
- data/lib/llmemory.rb +5 -0
- metadata +32 -1
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "base"
|
|
7
|
+
|
|
8
|
+
module Llmemory
|
|
9
|
+
module LongTerm
|
|
10
|
+
module Procedural
|
|
11
|
+
module Storages
|
|
12
|
+
# PostgreSQL backend. Each skill is stored as a JSONB `data` document
|
|
13
|
+
# (plus id/user_id/created_at and a denormalized search_text), mirroring
|
|
14
|
+
# the file-based DatabaseStorage pattern.
|
|
15
|
+
class DatabaseStorage < Base
|
|
16
|
+
def initialize(database_url: nil)
|
|
17
|
+
@database_url = database_url || Llmemory.configuration.database_url
|
|
18
|
+
@connection = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def save_skill(user_id, skill)
|
|
22
|
+
ensure_tables!
|
|
23
|
+
id = skill[:id] || skill["id"] || "skill_#{SecureRandom.hex(8)}"
|
|
24
|
+
data = symbolize(skill).merge(id: id, user_id: user_id)
|
|
25
|
+
data[:created_at] ||= Time.now.utc.iso8601
|
|
26
|
+
conn.exec_params(
|
|
27
|
+
"INSERT INTO llmemory_skills (id, user_id, data, search_text, created_at) " \
|
|
28
|
+
"VALUES ($1, $2, $3::jsonb, $4, $5) " \
|
|
29
|
+
"ON CONFLICT (id) DO UPDATE SET data = $3::jsonb, search_text = $4",
|
|
30
|
+
[id, user_id, JSON.generate(data), searchable_text(data), created_at_value(data)]
|
|
31
|
+
)
|
|
32
|
+
id
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def get_skill(user_id, id)
|
|
36
|
+
ensure_tables!
|
|
37
|
+
rows = conn.exec_params("SELECT data FROM llmemory_skills WHERE user_id = $1 AND id = $2", [user_id, id])
|
|
38
|
+
rows.any? ? parse_data(rows.first["data"]) : nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def list_skills(user_id, limit: nil)
|
|
42
|
+
ensure_tables!
|
|
43
|
+
sql = "SELECT data FROM llmemory_skills WHERE user_id = $1 ORDER BY created_at DESC"
|
|
44
|
+
sql += " LIMIT #{limit.to_i}" if limit && limit.to_i.positive?
|
|
45
|
+
conn.exec_params(sql, [user_id]).map { |r| parse_data(r["data"]) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def search_skills(user_id, query)
|
|
49
|
+
ensure_tables!
|
|
50
|
+
suffix, params = token_filter("search_text", query, 2)
|
|
51
|
+
conn.exec_params(
|
|
52
|
+
"SELECT data FROM llmemory_skills WHERE user_id = $1#{suffix} ORDER BY created_at DESC",
|
|
53
|
+
[user_id, *params]
|
|
54
|
+
).map { |r| parse_data(r["data"]) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def find_skills_by_name(user_id, name)
|
|
58
|
+
ensure_tables!
|
|
59
|
+
conn.exec_params(
|
|
60
|
+
"SELECT data FROM llmemory_skills WHERE user_id = $1 AND data->>'name' = $2",
|
|
61
|
+
[user_id, name.to_s]
|
|
62
|
+
).map { |r| parse_data(r["data"]) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def record_outcome(user_id, skill_id, success:)
|
|
66
|
+
ensure_tables!
|
|
67
|
+
data = get_skill(user_id, skill_id)
|
|
68
|
+
return nil unless data
|
|
69
|
+
key = success ? :success_count : :failure_count
|
|
70
|
+
data[key] = (data[key] || 0).to_i + 1
|
|
71
|
+
data[:updated_at] = Time.now.utc.iso8601
|
|
72
|
+
conn.exec_params(
|
|
73
|
+
"UPDATE llmemory_skills SET data = $3::jsonb, search_text = $4 WHERE user_id = $1 AND id = $2",
|
|
74
|
+
[user_id, skill_id, JSON.generate(data), searchable_text(data)]
|
|
75
|
+
)
|
|
76
|
+
data
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def count_skills(user_id)
|
|
80
|
+
ensure_tables!
|
|
81
|
+
conn.exec_params("SELECT COUNT(*) AS c FROM llmemory_skills WHERE user_id = $1", [user_id]).first["c"].to_i
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def delete_skills(user_id, ids)
|
|
85
|
+
ensure_tables!
|
|
86
|
+
Array(ids).sum do |id|
|
|
87
|
+
conn.exec_params("DELETE FROM llmemory_skills WHERE user_id = $1 AND id = $2", [user_id, id]).cmd_tuples
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def list_users
|
|
92
|
+
ensure_tables!
|
|
93
|
+
conn.exec("SELECT DISTINCT user_id FROM llmemory_skills").map { |r| r["user_id"] }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def conn
|
|
99
|
+
@connection ||= begin
|
|
100
|
+
require "pg"
|
|
101
|
+
PG.connect(@database_url)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def ensure_tables!
|
|
106
|
+
conn.exec(<<~SQL)
|
|
107
|
+
CREATE TABLE IF NOT EXISTS llmemory_skills (
|
|
108
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
109
|
+
user_id TEXT NOT NULL,
|
|
110
|
+
data JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
111
|
+
search_text TEXT,
|
|
112
|
+
created_at TIMESTAMPTZ NOT NULL
|
|
113
|
+
);
|
|
114
|
+
CREATE INDEX IF NOT EXISTS idx_llmemory_skills_user_id ON llmemory_skills(user_id);
|
|
115
|
+
SQL
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def token_filter(column, query, start_index)
|
|
119
|
+
tokens = Llmemory::Tokenizer.tokenize(query)
|
|
120
|
+
return ["", []] if tokens.empty?
|
|
121
|
+
likes = tokens.each_index.map { |i| "LOWER(#{column}) LIKE $#{start_index + i}" }
|
|
122
|
+
[" AND (#{likes.join(' OR ')})", tokens.map { |t| "%#{t}%" }]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def parse_data(value)
|
|
126
|
+
JSON.parse(value.to_s, symbolize_names: true)
|
|
127
|
+
rescue JSON::ParserError
|
|
128
|
+
{}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def symbolize(hash)
|
|
132
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def searchable_text(data)
|
|
136
|
+
[data[:name], data[:description], data[:body]].compact.join("\n")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def created_at_value(data)
|
|
140
|
+
ca = data[:created_at]
|
|
141
|
+
return Time.now.utc.iso8601 if ca.nil?
|
|
142
|
+
ca.respond_to?(:iso8601) ? ca.iso8601 : ca.to_s
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "base"
|
|
7
|
+
|
|
8
|
+
module Llmemory
|
|
9
|
+
module LongTerm
|
|
10
|
+
module Procedural
|
|
11
|
+
module Storages
|
|
12
|
+
class FileStorage < Base
|
|
13
|
+
def initialize(base_path: nil)
|
|
14
|
+
@base_path = base_path || Llmemory.configuration.long_term_storage_path || "./llmemory_data"
|
|
15
|
+
@base_path = File.expand_path(@base_path)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def save_skill(user_id, skill)
|
|
19
|
+
id = skill[:id] || skill["id"] || "skill_#{next_seq(user_id)}"
|
|
20
|
+
data = stringify_for_json(skill).merge("id" => id, "user_id" => user_id)
|
|
21
|
+
data["created_at"] ||= Time.now.iso8601(6)
|
|
22
|
+
File.write(skill_path(user_id, id), JSON.generate(data))
|
|
23
|
+
id
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def get_skill(user_id, id)
|
|
27
|
+
path = skill_path(user_id, id)
|
|
28
|
+
return nil unless File.file?(path)
|
|
29
|
+
load_skill(path)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def list_skills(user_id, limit: nil)
|
|
33
|
+
sorted = all_skills(user_id).sort_by { |s| s[:created_at] }.reverse
|
|
34
|
+
limit && limit.to_i.positive? ? sorted.first(limit.to_i) : sorted
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def search_skills(user_id, query)
|
|
38
|
+
return list_skills(user_id) if query.to_s.strip.empty?
|
|
39
|
+
all_skills(user_id).select { |s| Llmemory::Tokenizer.matches?(skill_text(s), query) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def find_skills_by_name(user_id, name)
|
|
43
|
+
all_skills(user_id).select { |s| s[:name].to_s == name.to_s }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def record_outcome(user_id, skill_id, success:)
|
|
47
|
+
skill = get_skill(user_id, skill_id)
|
|
48
|
+
return nil unless skill
|
|
49
|
+
key = success ? :success_count : :failure_count
|
|
50
|
+
skill[key] = (skill[key] || 0).to_i + 1
|
|
51
|
+
skill[:updated_at] = Time.now.iso8601(6)
|
|
52
|
+
File.write(skill_path(user_id, skill_id), JSON.generate(stringify_for_json(skill)))
|
|
53
|
+
skill
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def count_skills(user_id)
|
|
57
|
+
dir = user_path(user_id, "skills")
|
|
58
|
+
return 0 unless Dir.exist?(dir)
|
|
59
|
+
Dir.children(dir).count { |f| f.end_with?(".json") }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def delete_skills(user_id, ids)
|
|
63
|
+
Array(ids).map(&:to_s).count do |id|
|
|
64
|
+
path = skill_path(user_id, id)
|
|
65
|
+
next false unless File.file?(path)
|
|
66
|
+
File.delete(path)
|
|
67
|
+
true
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def list_users
|
|
72
|
+
return [] unless Dir.exist?(@base_path)
|
|
73
|
+
Dir.children(@base_path).select { |d| Dir.exist?(File.join(@base_path, d, "skills")) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def all_skills(user_id)
|
|
79
|
+
dir = user_path(user_id, "skills")
|
|
80
|
+
return [] unless Dir.exist?(dir)
|
|
81
|
+
Dir.children(dir).select { |f| f.end_with?(".json") }.map { |f| load_skill(File.join(dir, f)) }.compact
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def load_skill(path)
|
|
85
|
+
data = JSON.parse(File.read(path), symbolize_names: true)
|
|
86
|
+
data[:created_at] = parse_time(data[:created_at])
|
|
87
|
+
data[:updated_at] = parse_time(data[:updated_at]) if data[:updated_at]
|
|
88
|
+
data
|
|
89
|
+
rescue JSON::ParserError
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def skill_text(skill)
|
|
94
|
+
[skill[:name], skill[:description], skill[:body]].compact.join("\n")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def stringify_for_json(skill)
|
|
98
|
+
JSON.parse(JSON.generate(skill))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def user_path(user_id, *parts)
|
|
102
|
+
safe = user_id.to_s.gsub(%r{[^\w\-.]}, "_")
|
|
103
|
+
File.join(@base_path, safe, *parts)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def skill_path(user_id, id)
|
|
107
|
+
dir = user_path(user_id, "skills")
|
|
108
|
+
FileUtils.mkdir_p(dir)
|
|
109
|
+
File.join(dir, "#{id}.json")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def meta_path(user_id)
|
|
113
|
+
FileUtils.mkdir_p(user_path(user_id))
|
|
114
|
+
File.join(user_path(user_id), "meta.json")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def next_seq(user_id)
|
|
118
|
+
path = meta_path(user_id)
|
|
119
|
+
meta = File.file?(path) ? JSON.parse(File.read(path)) : {}
|
|
120
|
+
meta["skill_id_seq"] = (meta["skill_id_seq"] || 0) + 1
|
|
121
|
+
File.write(path, JSON.generate(meta))
|
|
122
|
+
meta["skill_id_seq"]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def parse_time(val)
|
|
126
|
+
return val if val.is_a?(Time)
|
|
127
|
+
Time.parse(val.to_s)
|
|
128
|
+
rescue ArgumentError
|
|
129
|
+
Time.now
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module LongTerm
|
|
7
|
+
module Procedural
|
|
8
|
+
module Storages
|
|
9
|
+
class MemoryStorage < Base
|
|
10
|
+
def initialize
|
|
11
|
+
@skills = Hash.new { |h, k| h[k] = [] }
|
|
12
|
+
@seq = 0
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def save_skill(user_id, skill)
|
|
16
|
+
@seq += 1
|
|
17
|
+
id = skill[:id] || skill["id"] || "skill_#{@seq}"
|
|
18
|
+
record = symbolize(skill).merge(id: id, user_id: user_id)
|
|
19
|
+
record[:created_at] ||= Time.now
|
|
20
|
+
@skills[user_id] << record
|
|
21
|
+
id
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def get_skill(user_id, id)
|
|
25
|
+
@skills[user_id].find { |s| s[:id] == id }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def list_skills(user_id, limit: nil)
|
|
29
|
+
sorted = @skills[user_id].sort_by { |s| s[:created_at] }.reverse
|
|
30
|
+
limit && limit.to_i.positive? ? sorted.first(limit.to_i) : sorted
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def search_skills(user_id, query)
|
|
34
|
+
return list_skills(user_id) if query.to_s.strip.empty?
|
|
35
|
+
@skills[user_id].select { |s| Llmemory::Tokenizer.matches?(skill_text(s), query) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def find_skills_by_name(user_id, name)
|
|
39
|
+
@skills[user_id].select { |s| s[:name].to_s == name.to_s }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def record_outcome(user_id, skill_id, success:)
|
|
43
|
+
skill = get_skill(user_id, skill_id)
|
|
44
|
+
return nil unless skill
|
|
45
|
+
key = success ? :success_count : :failure_count
|
|
46
|
+
skill[key] = (skill[key] || 0).to_i + 1
|
|
47
|
+
skill[:updated_at] = Time.now
|
|
48
|
+
skill
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def count_skills(user_id)
|
|
52
|
+
@skills[user_id].size
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def delete_skills(user_id, ids)
|
|
56
|
+
ids = Array(ids).map(&:to_s)
|
|
57
|
+
before = @skills[user_id].size
|
|
58
|
+
@skills[user_id].reject! { |s| ids.include?(s[:id].to_s) }
|
|
59
|
+
before - @skills[user_id].size
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def list_users
|
|
63
|
+
@skills.keys
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def symbolize(hash)
|
|
69
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def skill_text(skill)
|
|
73
|
+
[skill[:name], skill[:description], skill[:body]].compact.join("\n")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/llmemory/long_term.rb
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "memory_module"
|
|
3
4
|
require_relative "long_term/file_based"
|
|
4
5
|
require_relative "long_term/graph_based"
|
|
5
6
|
require_relative "long_term/episodic"
|
|
7
|
+
require_relative "long_term/procedural"
|
|
6
8
|
|
|
7
9
|
module Llmemory
|
|
8
10
|
module LongTerm
|
data/lib/llmemory/mcp/server.rb
CHANGED
|
@@ -11,6 +11,12 @@ require_relative "tools/memory_consolidate"
|
|
|
11
11
|
require_relative "tools/memory_stats"
|
|
12
12
|
require_relative "tools/memory_info"
|
|
13
13
|
require_relative "tools/memory_timeline_context"
|
|
14
|
+
require_relative "tools/memory_episode_record"
|
|
15
|
+
require_relative "tools/memory_episodes"
|
|
16
|
+
require_relative "tools/memory_skill_register"
|
|
17
|
+
require_relative "tools/memory_skill_report"
|
|
18
|
+
require_relative "tools/memory_skills"
|
|
19
|
+
require_relative "tools/memory_forget"
|
|
14
20
|
|
|
15
21
|
module Llmemory
|
|
16
22
|
module MCP
|
|
@@ -157,7 +163,13 @@ module Llmemory
|
|
|
157
163
|
Tools::MemoryAddMessage,
|
|
158
164
|
Tools::MemoryConsolidate,
|
|
159
165
|
Tools::MemoryStats,
|
|
160
|
-
Tools::MemoryInfo
|
|
166
|
+
Tools::MemoryInfo,
|
|
167
|
+
Tools::MemoryEpisodeRecord,
|
|
168
|
+
Tools::MemoryEpisodes,
|
|
169
|
+
Tools::MemorySkillRegister,
|
|
170
|
+
Tools::MemorySkillReport,
|
|
171
|
+
Tools::MemorySkills,
|
|
172
|
+
Tools::MemoryForget
|
|
161
173
|
]
|
|
162
174
|
end
|
|
163
175
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class MemoryEpisodeRecord < ::MCP::Tool
|
|
7
|
+
description "Record an experience (episodic memory): a trajectory of steps with an optional summary, outcome and importance. Use to remember what just happened so it can be retrieved or distilled into knowledge later."
|
|
8
|
+
|
|
9
|
+
input_schema(
|
|
10
|
+
properties: {
|
|
11
|
+
user_id: { type: "string", description: "User identifier" },
|
|
12
|
+
steps: {
|
|
13
|
+
type: "array",
|
|
14
|
+
description: "Ordered list of steps (objects with observation/action/result)",
|
|
15
|
+
items: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
observation: { type: "string" },
|
|
19
|
+
action: { type: "string" },
|
|
20
|
+
result: { type: "string" }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
summary: { type: "string", description: "Optional summary (derived from steps if omitted)" },
|
|
25
|
+
outcome: { type: "string", description: "Outcome label, e.g. 'success', 'failure', 'recovered'" },
|
|
26
|
+
importance: { type: "number", description: "Importance 0-1 (default 0.5)" }
|
|
27
|
+
},
|
|
28
|
+
required: ["user_id", "steps"]
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
def call(user_id:, steps:, summary: nil, outcome: nil, importance: nil, server_context: nil)
|
|
33
|
+
memory = Llmemory::LongTerm::Episodic::Memory.new(user_id: user_id)
|
|
34
|
+
id = memory.record_episode(
|
|
35
|
+
steps: Array(steps),
|
|
36
|
+
summary: summary,
|
|
37
|
+
outcome: outcome,
|
|
38
|
+
importance: importance.nil? ? 0.5 : importance.to_f
|
|
39
|
+
)
|
|
40
|
+
::MCP::Tool::Response.new([{ type: "text", text: "Episode recorded: #{id}" }])
|
|
41
|
+
rescue => e
|
|
42
|
+
::MCP::Tool::Response.new([{ type: "text", text: "Error recording episode: #{e.message}" }], error: true)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class MemoryEpisodes < ::MCP::Tool
|
|
7
|
+
description "List recent episodes (episodic memory) for a user. Optionally filter by a keyword query."
|
|
8
|
+
|
|
9
|
+
input_schema(
|
|
10
|
+
properties: {
|
|
11
|
+
user_id: { type: "string", description: "User identifier" },
|
|
12
|
+
query: { type: "string", description: "Optional keyword filter" },
|
|
13
|
+
limit: { type: "integer", description: "Max episodes to return (default 10)" }
|
|
14
|
+
},
|
|
15
|
+
required: ["user_id"]
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
def call(user_id:, query: nil, limit: nil, server_context: nil)
|
|
20
|
+
memory = Llmemory::LongTerm::Episodic::Memory.new(user_id: user_id)
|
|
21
|
+
cap = (limit || 10).to_i
|
|
22
|
+
episodes = if query.to_s.strip.empty?
|
|
23
|
+
memory.recent_episodes(limit: cap)
|
|
24
|
+
else
|
|
25
|
+
memory.search_candidates(query, top_k: cap).filter_map { |c| memory.find_episode(c[:id]) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
if episodes.empty?
|
|
29
|
+
return ::MCP::Tool::Response.new([{ type: "text", text: "No episodes for user #{user_id}." }])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
lines = episodes.map do |ep|
|
|
33
|
+
"[#{ep.id}] (importance: #{ep.importance}; outcome: #{ep.outcome || 'n/a'}) #{ep.summary || ep.searchable_text[0, 120]}"
|
|
34
|
+
end
|
|
35
|
+
::MCP::Tool::Response.new([{ type: "text", text: lines.join("\n") }])
|
|
36
|
+
rescue => e
|
|
37
|
+
::MCP::Tool::Response.new([{ type: "text", text: "Error listing episodes: #{e.message}" }], error: true)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class MemoryForget < ::MCP::Tool
|
|
7
|
+
description "Remove entries from a memory by their ids (the ids returned by retrieval / listing tools), recording the removal in the audit log. memory_type: file_based | graph_based | episodic | procedural."
|
|
8
|
+
|
|
9
|
+
input_schema(
|
|
10
|
+
properties: {
|
|
11
|
+
user_id: { type: "string", description: "User identifier" },
|
|
12
|
+
memory_type: { type: "string", description: "file_based | graph_based | episodic | procedural" },
|
|
13
|
+
ids: { type: "array", items: { type: "string" }, description: "Entry ids to forget" },
|
|
14
|
+
reason: { type: "string", description: "Optional reason (recorded in audit)" }
|
|
15
|
+
},
|
|
16
|
+
required: ["user_id", "memory_type", "ids"]
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def call(user_id:, memory_type:, ids:, reason: nil, server_context: nil)
|
|
21
|
+
memory = build_memory(user_id, memory_type)
|
|
22
|
+
removed = memory.forget(ids: Array(ids), reason: reason)
|
|
23
|
+
::MCP::Tool::Response.new([{
|
|
24
|
+
type: "text",
|
|
25
|
+
text: "Forgot #{removed} entries from #{memory_type} memory for user #{user_id}."
|
|
26
|
+
}])
|
|
27
|
+
rescue NotImplementedError => e
|
|
28
|
+
::MCP::Tool::Response.new([{ type: "text", text: "Not supported: #{e.message}" }], error: true)
|
|
29
|
+
rescue => e
|
|
30
|
+
::MCP::Tool::Response.new([{ type: "text", text: "Error forgetting: #{e.message}" }], error: true)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def build_memory(user_id, memory_type)
|
|
36
|
+
case memory_type.to_s
|
|
37
|
+
when "file_based"
|
|
38
|
+
Llmemory::LongTerm::FileBased::Memory.new(user_id: user_id)
|
|
39
|
+
when "graph_based"
|
|
40
|
+
Llmemory::LongTerm::GraphBased::Memory.new(user_id: user_id)
|
|
41
|
+
when "episodic"
|
|
42
|
+
Llmemory::LongTerm::Episodic::Memory.new(user_id: user_id)
|
|
43
|
+
when "procedural"
|
|
44
|
+
Llmemory::LongTerm::Procedural::Memory.new(user_id: user_id)
|
|
45
|
+
else
|
|
46
|
+
raise ArgumentError, "Unknown memory_type: #{memory_type} (expected file_based|graph_based|episodic|procedural)"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -59,8 +59,10 @@ module Llmemory
|
|
|
59
59
|
items = storage.search_items(user_id, query)
|
|
60
60
|
return "" if items.empty?
|
|
61
61
|
|
|
62
|
-
#
|
|
63
|
-
|
|
62
|
+
# Anchor on the most precise match: keyword search is recall-oriented
|
|
63
|
+
# (tokenized OR), so prefer the item whose content contains the full
|
|
64
|
+
# query verbatim, falling back to the first match.
|
|
65
|
+
top_item = best_match(items, query)
|
|
64
66
|
item_id = top_item[:id] || top_item["id"]
|
|
65
67
|
return "" unless item_id
|
|
66
68
|
|
|
@@ -70,6 +72,12 @@ module Llmemory
|
|
|
70
72
|
""
|
|
71
73
|
end
|
|
72
74
|
|
|
75
|
+
def best_match(items, query)
|
|
76
|
+
q = query.to_s.downcase.strip
|
|
77
|
+
return items.first if q.empty?
|
|
78
|
+
items.find { |i| (i[:content] || i["content"]).to_s.downcase.include?(q) } || items.first
|
|
79
|
+
end
|
|
80
|
+
|
|
73
81
|
def build_storage
|
|
74
82
|
if Llmemory.configuration.long_term_type.to_s == "graph_based"
|
|
75
83
|
LongTerm::GraphBased::Storages.build
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class MemorySkillRegister < ::MCP::Tool
|
|
7
|
+
description "Register a reusable skill (procedural memory): a prompt, template or code snippet the agent can retrieve later. Re-registering the same name auto-increments the version."
|
|
8
|
+
|
|
9
|
+
input_schema(
|
|
10
|
+
properties: {
|
|
11
|
+
user_id: { type: "string", description: "User identifier" },
|
|
12
|
+
name: { type: "string", description: "Short identifier (skills with the same name get auto-versioned)" },
|
|
13
|
+
body: { type: "string", description: "The skill content (prompt / template / code)" },
|
|
14
|
+
description: { type: "string", description: "Optional human-readable description" },
|
|
15
|
+
kind: { type: "string", description: "prompt | template | code (default: prompt)" }
|
|
16
|
+
},
|
|
17
|
+
required: ["user_id", "name", "body"]
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def call(user_id:, name:, body:, description: nil, kind: nil, server_context: nil)
|
|
22
|
+
memory = Llmemory::LongTerm::Procedural::Memory.new(user_id: user_id)
|
|
23
|
+
id = memory.register_skill(
|
|
24
|
+
name: name, body: body, description: description,
|
|
25
|
+
kind: kind || Llmemory::LongTerm::Procedural::Skill::DEFAULT_KIND
|
|
26
|
+
)
|
|
27
|
+
::MCP::Tool::Response.new([{ type: "text", text: "Skill registered: #{id} (#{name})" }])
|
|
28
|
+
rescue => e
|
|
29
|
+
::MCP::Tool::Response.new([{ type: "text", text: "Error registering skill: #{e.message}" }], error: true)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class MemorySkillReport < ::MCP::Tool
|
|
7
|
+
description "Report the outcome of applying a skill (success or failure). Feeds retrieval ranking: proven skills surface higher next time."
|
|
8
|
+
|
|
9
|
+
input_schema(
|
|
10
|
+
properties: {
|
|
11
|
+
user_id: { type: "string", description: "User identifier" },
|
|
12
|
+
skill_id: { type: "string", description: "Skill id (from MemorySkillRegister / MemorySkills)" },
|
|
13
|
+
success: { type: "boolean", description: "True if the skill worked; false otherwise" }
|
|
14
|
+
},
|
|
15
|
+
required: ["user_id", "skill_id", "success"]
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
def call(user_id:, skill_id:, success:, server_context: nil)
|
|
20
|
+
memory = Llmemory::LongTerm::Procedural::Memory.new(user_id: user_id)
|
|
21
|
+
skill = memory.report_outcome(skill_id, success: success == true)
|
|
22
|
+
if skill.nil?
|
|
23
|
+
return ::MCP::Tool::Response.new([{ type: "text", text: "Skill not found: #{skill_id}" }], error: true)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
text = "Outcome recorded for #{skill.name} (#{skill_id}): success #{skill.success_count} / failure #{skill.failure_count} (rate #{format('%.2f', skill.success_rate)})"
|
|
27
|
+
::MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
28
|
+
rescue => e
|
|
29
|
+
::MCP::Tool::Response.new([{ type: "text", text: "Error reporting outcome: #{e.message}" }], error: true)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|