llmemory 0.2.0 → 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 +7 -2
- data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +20 -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 +4 -0
- data/lib/llmemory/long_term/episodic/memory.rb +71 -16
- 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/database_storage.rb +135 -0
- data/lib/llmemory/long_term/episodic/storages/file_storage.rb +2 -3
- data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +2 -3
- 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 +77 -60
- data/lib/llmemory/long_term/procedural/memory.rb +71 -17
- data/lib/llmemory/long_term/procedural/storage.rb +7 -5
- 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/database_storage.rb +148 -0
- data/lib/llmemory/long_term/procedural/storages/file_storage.rb +2 -3
- data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +2 -3
- 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 +28 -3
- data/lib/llmemory/retrieval/bm25_scorer.rb +1 -1
- data/lib/llmemory/retrieval/mmr_reranker.rb +1 -1
- data/lib/llmemory/short_term/session_lifecycle.rb +19 -3
- 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.rb +1 -0
- metadata +18 -1
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
# ActiveRecord backend. Stores each skill as a JSONB `data` document; AR
|
|
13
|
+
# auto-deserializes jsonb to a Hash (string keys), which Skill.from_h
|
|
14
|
+
# handles. Mirrors the file-based ActiveRecordStorage pattern.
|
|
15
|
+
class ActiveRecordStorage < Base
|
|
16
|
+
def initialize
|
|
17
|
+
self.class.load_models!
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.load_models!
|
|
21
|
+
return if @models_loaded
|
|
22
|
+
require "active_record"
|
|
23
|
+
require_relative "active_record_models"
|
|
24
|
+
@models_loaded = true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def save_skill(user_id, skill)
|
|
28
|
+
id = skill[:id] || skill["id"] || "skill_#{SecureRandom.hex(8)}"
|
|
29
|
+
data = stringify(skill).merge("id" => id, "user_id" => user_id)
|
|
30
|
+
data["created_at"] ||= Time.now.utc.iso8601
|
|
31
|
+
rec = LlmemorySkill.find_or_initialize_by(id: id)
|
|
32
|
+
rec.user_id = user_id
|
|
33
|
+
rec.data = data
|
|
34
|
+
rec.search_text = searchable_text(data)
|
|
35
|
+
rec.created_at ||= Time.current
|
|
36
|
+
rec.save!
|
|
37
|
+
id
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def get_skill(user_id, id)
|
|
41
|
+
LlmemorySkill.find_by(user_id: user_id, id: id)&.data
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def list_skills(user_id, limit: nil)
|
|
45
|
+
scope = LlmemorySkill.where(user_id: user_id).order(created_at: :desc)
|
|
46
|
+
scope = scope.limit(limit) if limit && limit.to_i.positive?
|
|
47
|
+
scope.map(&:data)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def search_skills(user_id, query)
|
|
51
|
+
token_scope(LlmemorySkill.where(user_id: user_id), "search_text", query)
|
|
52
|
+
.order(created_at: :desc).map(&:data)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def find_skills_by_name(user_id, name)
|
|
56
|
+
LlmemorySkill.where(user_id: user_id).where("data->>'name' = ?", name.to_s).map(&:data)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def record_outcome(user_id, skill_id, success:)
|
|
60
|
+
rec = LlmemorySkill.find_by(user_id: user_id, id: skill_id)
|
|
61
|
+
return nil unless rec
|
|
62
|
+
data = rec.data || {}
|
|
63
|
+
key = success ? "success_count" : "failure_count"
|
|
64
|
+
data[key] = (data[key] || 0).to_i + 1
|
|
65
|
+
data["updated_at"] = Time.now.utc.iso8601
|
|
66
|
+
rec.data = data
|
|
67
|
+
rec.search_text = searchable_text(data)
|
|
68
|
+
rec.save!
|
|
69
|
+
data
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def count_skills(user_id)
|
|
73
|
+
LlmemorySkill.where(user_id: user_id).count
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def delete_skills(user_id, ids)
|
|
77
|
+
LlmemorySkill.where(user_id: user_id, id: Array(ids).map(&:to_s)).delete_all
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def list_users
|
|
81
|
+
LlmemorySkill.distinct.pluck(:user_id)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def token_scope(scope, column, query)
|
|
87
|
+
tokens = Llmemory::Tokenizer.tokenize(query)
|
|
88
|
+
return scope if tokens.empty?
|
|
89
|
+
clause = tokens.map { "LOWER(#{column}) LIKE LOWER(?)" }.join(" OR ")
|
|
90
|
+
scope.where(clause, *tokens.map { |t| "%#{t}%" })
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def stringify(hash)
|
|
94
|
+
JSON.parse(JSON.generate(hash))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def searchable_text(data)
|
|
98
|
+
[data["name"], data["description"], data["body"]].compact.join("\n")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -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
|
|
@@ -35,9 +35,8 @@ module Llmemory
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def search_skills(user_id, query)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
all_skills(user_id).select { |s| skill_text(s).downcase.include?(q) }
|
|
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) }
|
|
41
40
|
end
|
|
42
41
|
|
|
43
42
|
def find_skills_by_name(user_id, name)
|
|
@@ -31,9 +31,8 @@ module Llmemory
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def search_skills(user_id, query)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
@skills[user_id].select { |s| skill_text(s).downcase.include?(q) }
|
|
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) }
|
|
37
36
|
end
|
|
38
37
|
|
|
39
38
|
def find_skills_by_name(user_id, name)
|
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
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class MemorySkills < ::MCP::Tool
|
|
7
|
+
description "List registered skills (procedural memory) for a user, ranked by proven utility when a query is given."
|
|
8
|
+
|
|
9
|
+
input_schema(
|
|
10
|
+
properties: {
|
|
11
|
+
user_id: { type: "string", description: "User identifier" },
|
|
12
|
+
query: { type: "string", description: "Optional keyword to filter skills" },
|
|
13
|
+
limit: { type: "integer", description: "Max skills 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::Procedural::Memory.new(user_id: user_id)
|
|
21
|
+
cap = (limit || 10).to_i
|
|
22
|
+
skills = if query.to_s.strip.empty?
|
|
23
|
+
memory.skills(limit: cap)
|
|
24
|
+
else
|
|
25
|
+
memory.search_candidates(query, top_k: cap).filter_map { |c| memory.get_skill(c[:id]) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
if skills.empty?
|
|
29
|
+
return ::MCP::Tool::Response.new([{ type: "text", text: "No skills for user #{user_id}." }])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
lines = skills.map do |s|
|
|
33
|
+
"[#{s.id}] #{s.name} v#{s.version} (#{s.kind}) — success rate #{format('%.2f', s.success_rate)} (#{s.success_count}/#{s.success_count + s.failure_count})"
|
|
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 skills: #{e.message}" }], error: true)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/llmemory/memory.rb
CHANGED
|
@@ -10,11 +10,13 @@ module Llmemory
|
|
|
10
10
|
DEFAULT_SESSION_ID = "default"
|
|
11
11
|
STATE_KEY_MESSAGES = :messages
|
|
12
12
|
|
|
13
|
-
def initialize(user_id:, session_id: DEFAULT_SESSION_ID, checkpoint: nil, long_term: nil, long_term_type: nil, retrieval_engine: nil, working_memory: nil, api_key: nil)
|
|
13
|
+
def initialize(user_id:, session_id: DEFAULT_SESSION_ID, checkpoint: nil, long_term: nil, long_term_type: nil, retrieval_engine: nil, working_memory: nil, episodic: nil, procedural: nil, api_key: nil)
|
|
14
14
|
@user_id = user_id
|
|
15
15
|
@session_id = session_id
|
|
16
16
|
@checkpoint = checkpoint || ShortTerm::Checkpoint.new(user_id: user_id, session_id: session_id)
|
|
17
17
|
@working_memory = working_memory
|
|
18
|
+
@episodic = episodic
|
|
19
|
+
@procedural = procedural
|
|
18
20
|
@llm = api_key.to_s.empty? ? nil : Llmemory::LLM.client(api_key: api_key)
|
|
19
21
|
type = long_term_type || Llmemory.configuration.long_term_type || :file_based
|
|
20
22
|
@long_term = long_term || build_long_term(type)
|
|
@@ -22,12 +24,35 @@ module Llmemory
|
|
|
22
24
|
end
|
|
23
25
|
|
|
24
26
|
# Structured working memory for this session (CoALA working memory),
|
|
25
|
-
# parallel to the message checkpoint. Lazily built
|
|
26
|
-
# unless an agent uses it.
|
|
27
|
+
# parallel to the message checkpoint. Lazily built.
|
|
27
28
|
def working_memory
|
|
28
29
|
@working_memory ||= WorkingMemory.new(user_id: @user_id, session_id: @session_id)
|
|
29
30
|
end
|
|
30
31
|
|
|
32
|
+
# Episodic long-term memory (CoALA): records and retrieves agent trajectories.
|
|
33
|
+
# Additive — coexists with the semantic store (file/graph). Lazily built.
|
|
34
|
+
def episodic
|
|
35
|
+
@episodic ||= LongTerm::Episodic::Memory.new(user_id: @user_id)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Procedural long-term memory (Voyager-style skill library). Lazily built.
|
|
39
|
+
def procedural
|
|
40
|
+
@procedural ||= LongTerm::Procedural::Memory.new(user_id: @user_id)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Reflects over recent episodes and writes distilled insights to the
|
|
44
|
+
# semantic store (file/graph) with provenance back to source episodes.
|
|
45
|
+
def reflect!(window: 10, category: "insights")
|
|
46
|
+
Reflection::Reflector.new(episodic: episodic, semantic: @long_term, llm: @llm)
|
|
47
|
+
.reflect(window: window, category: category)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Reasoning action: render a prompt from working memory, call the LLM, write
|
|
51
|
+
# the result back. Composable; does not touch long-term memory.
|
|
52
|
+
def reason(template:, into: Actions::Reason::DEFAULT_SLOT, parse: nil)
|
|
53
|
+
Actions::Reason.call(working_memory: working_memory, template: template, into: into, parse: parse, llm: @llm)
|
|
54
|
+
end
|
|
55
|
+
|
|
31
56
|
def add_message(role:, content:)
|
|
32
57
|
msgs = messages
|
|
33
58
|
msgs << { role: role.to_sym, content: content.to_s }
|