llmemory 0.2.0 → 0.2.2
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 +54 -3
- data/app/controllers/llmemory/dashboard/application_controller.rb +15 -1
- data/app/controllers/llmemory/dashboard/episodic_controller.rb +22 -0
- data/app/controllers/llmemory/dashboard/forget_log_controller.rb +12 -0
- data/app/controllers/llmemory/dashboard/maintenance_controller.rb +92 -0
- data/app/controllers/llmemory/dashboard/procedural_controller.rb +22 -0
- data/app/controllers/llmemory/dashboard/reflection_controller.rb +37 -0
- data/app/controllers/llmemory/dashboard/working_controller.rb +14 -0
- data/app/views/llmemory/dashboard/episodic/index.html.erb +37 -0
- data/app/views/llmemory/dashboard/forget_log/show.html.erb +23 -0
- data/app/views/llmemory/dashboard/maintenance/show.html.erb +65 -0
- data/app/views/llmemory/dashboard/procedural/index.html.erb +38 -0
- data/app/views/llmemory/dashboard/reflection/show.html.erb +29 -0
- data/app/views/llmemory/dashboard/users/show.html.erb +16 -0
- data/app/views/llmemory/dashboard/working/show.html.erb +20 -0
- data/config/routes.rb +14 -0
- data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +22 -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/maintain.rb +62 -0
- data/lib/llmemory/cli/commands/mine_skills.rb +50 -0
- data/lib/llmemory/cli/commands/procedural.rb +44 -0
- data/lib/llmemory/cli/commands/working.rb +31 -0
- data/lib/llmemory/cli.rb +18 -0
- data/lib/llmemory/configuration.rb +11 -1
- data/lib/llmemory/instrumentation.rb +33 -0
- data/lib/llmemory/llm/anthropic.rb +19 -15
- data/lib/llmemory/llm/openai.rb +16 -12
- data/lib/llmemory/long_term/episodic/memory.rb +94 -26
- 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 +103 -0
- data/lib/llmemory/long_term/episodic/storages/base.rb +15 -2
- data/lib/llmemory/long_term/episodic/storages/database_storage.rb +156 -0
- data/lib/llmemory/long_term/episodic/storages/file_storage.rb +28 -8
- data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +36 -6
- data/lib/llmemory/long_term/file_based/memory.rb +12 -4
- data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +15 -6
- data/lib/llmemory/long_term/file_based/storages/base.rb +2 -2
- data/lib/llmemory/long_term/file_based/storages/database_storage.rb +20 -8
- data/lib/llmemory/long_term/file_based/storages/file_storage.rb +6 -6
- data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +6 -6
- data/lib/llmemory/long_term/graph_based/memory.rb +89 -64
- data/lib/llmemory/long_term/graph_based/storages/active_record_storage.rb +4 -2
- data/lib/llmemory/long_term/graph_based/storages/base.rb +2 -2
- data/lib/llmemory/long_term/graph_based/storages/memory_storage.rb +4 -2
- data/lib/llmemory/long_term/procedural/memory.rb +97 -30
- data/lib/llmemory/long_term/procedural/skill.rb +6 -2
- 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 +114 -0
- data/lib/llmemory/long_term/procedural/storages/base.rb +14 -1
- data/lib/llmemory/long_term/procedural/storages/database_storage.rb +169 -0
- data/lib/llmemory/long_term/procedural/storages/file_storage.rb +29 -9
- data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +37 -7
- data/lib/llmemory/maintenance/cognitive_pass.rb +109 -0
- data/lib/llmemory/maintenance/ttl_expiry.rb +50 -0
- data/lib/llmemory/maintenance.rb +2 -0
- data/lib/llmemory/mcp/server.rb +17 -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_maintain.rb +53 -0
- data/lib/llmemory/mcp/tools/memory_mine_skills.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 +48 -3
- data/lib/llmemory/memory_module.rb +13 -6
- data/lib/llmemory/reflection/reflector.rb +24 -20
- data/lib/llmemory/retrieval/bm25_scorer.rb +1 -1
- data/lib/llmemory/retrieval/engine.rb +25 -16
- data/lib/llmemory/retrieval/mmr_reranker.rb +1 -1
- data/lib/llmemory/short_term/session_lifecycle.rb +19 -3
- data/lib/llmemory/skill_mining/miner.rb +163 -0
- data/lib/llmemory/skill_mining.rb +8 -0
- data/lib/llmemory/tokenizer.rb +27 -0
- data/lib/llmemory/vector_store/active_record_store.rb +4 -3
- data/lib/llmemory/vector_store/openai_embeddings.rb +11 -7
- data/lib/llmemory/vector_store.rb +14 -0
- data/lib/llmemory/version.rb +1 -1
- data/lib/llmemory.rb +3 -0
- metadata +39 -1
|
@@ -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
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class MemoryMaintain < ::MCP::Tool
|
|
7
|
+
description "Run the cognitive maintenance pass for a user: reflect (episodes -> insights), mine skills (episodes -> procedural), and expire entries past their TTL. Each step is isolated; a failure in one is reported and does not abort the others. Returns a summary report."
|
|
8
|
+
|
|
9
|
+
input_schema(
|
|
10
|
+
properties: {
|
|
11
|
+
user_id: { type: "string", description: "User identifier" },
|
|
12
|
+
reflect: { type: "boolean", description: "Distill insights from recent episodes (default true)" },
|
|
13
|
+
mine_skills: { type: "boolean", description: "Mine reusable skills from episodes and register them (default: config.skill_mining_enabled)" },
|
|
14
|
+
expire: { type: "boolean", description: "Soft-archive entries past their TTL (default true)" },
|
|
15
|
+
reflection_window: { type: "integer", description: "Episodes to reflect over (default 10)" },
|
|
16
|
+
mining_window: { type: "integer", description: "Episodes to mine for skills (default 20)" }
|
|
17
|
+
},
|
|
18
|
+
required: ["user_id"]
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def call(user_id:, reflect: true, mine_skills: nil, expire: true,
|
|
23
|
+
reflection_window: nil, mining_window: nil, server_context: nil)
|
|
24
|
+
opts = { reflect: reflect, expire: expire }
|
|
25
|
+
opts[:mine_skills] = mine_skills unless mine_skills.nil?
|
|
26
|
+
opts[:reflection_window] = reflection_window.to_i unless reflection_window.nil?
|
|
27
|
+
opts[:mining_window] = mining_window.to_i unless mining_window.nil?
|
|
28
|
+
|
|
29
|
+
report = Llmemory::Maintenance::CognitivePass.run!(user_id, **opts)
|
|
30
|
+
::MCP::Tool::Response.new([{ type: "text", text: format_report(user_id, report) }])
|
|
31
|
+
rescue => e
|
|
32
|
+
::MCP::Tool::Response.new([{ type: "text", text: "Error running maintenance pass: #{e.message}" }], error: true)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def format_report(user_id, report)
|
|
38
|
+
expired = report[:expired] || {}
|
|
39
|
+
lines = [
|
|
40
|
+
"Cognitive pass for #{user_id}:",
|
|
41
|
+
" insights: #{Array(report[:insights]).size}",
|
|
42
|
+
" skills mined: #{Array(report[:mined]).size}",
|
|
43
|
+
" expired: episodic=#{expired[:episodic] || 0} procedural=#{expired[:procedural] || 0}"
|
|
44
|
+
]
|
|
45
|
+
errors = report[:errors] || {}
|
|
46
|
+
lines << " errors: #{errors.map { |k, v| "#{k}: #{v}" }.join('; ')}" unless errors.empty?
|
|
47
|
+
lines.join("\n")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class MemoryMineSkills < ::MCP::Tool
|
|
7
|
+
description "Mine reusable skills from a user's successful episode trajectories (procedural learning). Human-in-the-loop by default: returns skill *proposals* and writes nothing. Set auto_register=true to register them in procedural memory (with provenance back to the source episodes)."
|
|
8
|
+
|
|
9
|
+
input_schema(
|
|
10
|
+
properties: {
|
|
11
|
+
user_id: { type: "string", description: "User identifier" },
|
|
12
|
+
window: { type: "integer", description: "Episodes to mine (default 20)" },
|
|
13
|
+
outcomes: { type: "array", items: { type: "string" }, description: "Optional allowlist of outcome labels to pre-filter episodes (e.g. ['success'])" },
|
|
14
|
+
auto_register: { type: "boolean", description: "Register the proposals instead of only returning them (default false)" }
|
|
15
|
+
},
|
|
16
|
+
required: ["user_id"]
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def call(user_id:, window: nil, outcomes: nil, auto_register: false, server_context: nil)
|
|
21
|
+
episodic = Llmemory::LongTerm::Episodic::Memory.new(user_id: user_id)
|
|
22
|
+
procedural = Llmemory::LongTerm::Procedural::Memory.new(user_id: user_id)
|
|
23
|
+
result = Llmemory::SkillMining::Miner.new(episodic: episodic, procedural: procedural).mine(
|
|
24
|
+
window: (window || Llmemory::SkillMining::Miner::DEFAULT_WINDOW).to_i,
|
|
25
|
+
outcomes: outcomes,
|
|
26
|
+
auto_register: auto_register
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
::MCP::Tool::Response.new([{ type: "text", text: format_result(user_id, result, auto_register) }])
|
|
30
|
+
rescue => e
|
|
31
|
+
::MCP::Tool::Response.new([{ type: "text", text: "Error mining skills: #{e.message}" }], error: true)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def format_result(user_id, result, auto_register)
|
|
37
|
+
return "No skills could be mined for user #{user_id}." if result.empty?
|
|
38
|
+
|
|
39
|
+
if auto_register
|
|
40
|
+
"Registered #{result.size} mined skill(s): #{result.join(', ')}"
|
|
41
|
+
else
|
|
42
|
+
lines = ["#{result.size} skill proposal(s) for user #{user_id} (not registered):"]
|
|
43
|
+
result.each do |p|
|
|
44
|
+
lines << " - #{p[:name]} (#{p[:kind]}, confidence: #{p[:confidence]}): #{p[:description] || p[:body]}"
|
|
45
|
+
end
|
|
46
|
+
lines.join("\n")
|
|
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,55 @@ 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
|
+
|
|
56
|
+
# Mines recent episodes for reusable skills (Voyager-style). Human-in-the-loop
|
|
57
|
+
# by default: returns skill proposals and writes nothing. With
|
|
58
|
+
# `auto_register: true`, registers them in procedural memory (with provenance
|
|
59
|
+
# back to the source episodes) and returns the new skill ids.
|
|
60
|
+
def mine_skills!(window: SkillMining::Miner::DEFAULT_WINDOW, outcomes: nil, auto_register: false)
|
|
61
|
+
SkillMining::Miner.new(episodic: episodic, procedural: procedural, llm: @llm)
|
|
62
|
+
.mine(window: window, outcomes: outcomes, auto_register: auto_register)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Cognitive maintenance pass: consolidate -> reflect -> mine skills -> expire,
|
|
66
|
+
# in one step, closing the CoALA learning loop. Each step is isolated; a
|
|
67
|
+
# failure in one is captured in the report and never aborts the others.
|
|
68
|
+
def maintain!(**opts)
|
|
69
|
+
Maintenance::CognitivePass.run!(
|
|
70
|
+
@user_id,
|
|
71
|
+
memory: self, episodic: episodic, procedural: procedural, semantic: @long_term, llm: @llm,
|
|
72
|
+
**opts
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
31
76
|
def add_message(role:, content:)
|
|
32
77
|
msgs = messages
|
|
33
78
|
msgs << { role: role.to_sym, content: content.to_s }
|
|
@@ -6,10 +6,10 @@ module Llmemory
|
|
|
6
6
|
# abstractions; this mixin gives any memory store the same agent-facing
|
|
7
7
|
# surface so frameworks can treat them polymorphically:
|
|
8
8
|
#
|
|
9
|
-
# read(query, user_id:, limit:)
|
|
10
|
-
# write(payload, ...)
|
|
11
|
-
# list(user_id:, limit:)
|
|
12
|
-
# stats(user_id:)
|
|
9
|
+
# read(query, user_id:, limit:) -> relevant entries (retrieval)
|
|
10
|
+
# write(payload, ...) -> ingest into the store (learning)
|
|
11
|
+
# list(user_id:, limit:, offset:) -> enumerate stored entries (paginated)
|
|
12
|
+
# stats(user_id:) -> counts and metadata
|
|
13
13
|
#
|
|
14
14
|
# `read` defaults to the de-facto `search_candidates` interface the retrieval
|
|
15
15
|
# Engine already relies on. `write`, `list` and `stats` are implemented by each
|
|
@@ -30,7 +30,7 @@ module Llmemory
|
|
|
30
30
|
raise NotImplementedError, "#{self.class} must implement #write"
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def list(user_id: nil, limit: nil)
|
|
33
|
+
def list(user_id: nil, limit: nil, offset: nil)
|
|
34
34
|
raise NotImplementedError, "#{self.class} must implement #list"
|
|
35
35
|
end
|
|
36
36
|
|
|
@@ -40,9 +40,16 @@ module Llmemory
|
|
|
40
40
|
|
|
41
41
|
# Removes entries by id (the same ids returned by #read) and records the
|
|
42
42
|
# removal in a ForgetLog audit. Returns the number of entries removed.
|
|
43
|
+
#
|
|
44
|
+
# mode:
|
|
45
|
+
# :soft (default) — soft-archive: entries are excluded from list/search/
|
|
46
|
+
# retrieval but remain accessible by id (think "trash"). Reversible if
|
|
47
|
+
# the store supports it.
|
|
48
|
+
# :hard — physical deletion. Irreversible.
|
|
49
|
+
#
|
|
43
50
|
# Implemented by stores with a clear deletion model; others may not support
|
|
44
51
|
# it (CoALA-style "unlearning" is understudied; deletion semantics differ).
|
|
45
|
-
def forget(ids:, reason: nil)
|
|
52
|
+
def forget(ids:, reason: nil, mode: :soft)
|
|
46
53
|
raise NotImplementedError, "#{self.class} does not support #forget"
|
|
47
54
|
end
|
|
48
55
|
|
|
@@ -30,27 +30,31 @@ module Llmemory
|
|
|
30
30
|
# Reflects over the most recent `window` episodes and writes the resulting
|
|
31
31
|
# insights to semantic memory. Returns the ids of the stored insights.
|
|
32
32
|
def reflect(window: 10, category: DEFAULT_CATEGORY)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
33
|
+
result = []
|
|
34
|
+
Llmemory::Instrumentation.instrument(:reflect, window: window, category: category) do
|
|
35
|
+
episodes = @episodic.recent_episodes(limit: window)
|
|
36
|
+
next if episodes.empty?
|
|
37
|
+
|
|
38
|
+
insights = distill(episodes)
|
|
39
|
+
next if insights.empty?
|
|
40
|
+
|
|
41
|
+
sources = episodes.map(&:id).compact.map { |id| { type: "episode", id: id } }
|
|
42
|
+
|
|
43
|
+
result = insights.filter_map do |insight|
|
|
44
|
+
provenance = Llmemory::Provenance.build(
|
|
45
|
+
method: "reflection",
|
|
46
|
+
sources: sources,
|
|
47
|
+
confidence: insight[:confidence]
|
|
48
|
+
)
|
|
49
|
+
@semantic.remember_fact(
|
|
50
|
+
content: insight[:content],
|
|
51
|
+
category: category,
|
|
52
|
+
importance: insight[:confidence] || DEFAULT_IMPORTANCE,
|
|
53
|
+
provenance: provenance
|
|
54
|
+
)
|
|
55
|
+
end
|
|
53
56
|
end
|
|
57
|
+
result
|
|
54
58
|
end
|
|
55
59
|
|
|
56
60
|
private
|
|
@@ -24,9 +24,13 @@ module Llmemory
|
|
|
24
24
|
|
|
25
25
|
def retrieve_for_inference(user_message, user_id: nil, max_tokens: nil)
|
|
26
26
|
user_id ||= @memory.respond_to?(:user_id) ? @memory.user_id : nil
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
result = nil
|
|
28
|
+
Llmemory::Instrumentation.instrument(:retrieve, user_id: user_id, query_chars: user_message.to_s.length) do
|
|
29
|
+
search_query = generate_query(user_message)
|
|
30
|
+
ranked = ranked_candidates(search_query, user_id, user_message)
|
|
31
|
+
result = @assembler.assemble(ranked, max_tokens: max_tokens)
|
|
32
|
+
end
|
|
33
|
+
result
|
|
30
34
|
end
|
|
31
35
|
|
|
32
36
|
# Multi-hop retrieval (CoALA: integrating retrieval and reasoning). After
|
|
@@ -42,21 +46,26 @@ module Llmemory
|
|
|
42
46
|
user_id ||= @memory.respond_to?(:user_id) ? @memory.user_id : nil
|
|
43
47
|
reasoner ||= method(:default_followup_query)
|
|
44
48
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
hop
|
|
54
|
-
|
|
49
|
+
final = nil
|
|
50
|
+
hops_done = 0
|
|
51
|
+
Llmemory::Instrumentation.instrument(:iterative_retrieve, user_id: user_id, query_chars: user_message.to_s.length, max_hops: max_hops) do
|
|
52
|
+
query = generate_query(user_message)
|
|
53
|
+
seen = []
|
|
54
|
+
accumulated = []
|
|
55
|
+
hop = 0
|
|
56
|
+
|
|
57
|
+
while hop < max_hops && live_query?(query) && !seen.include?(query)
|
|
58
|
+
seen << query
|
|
59
|
+
accumulated = merge_candidates(accumulated, ranked_candidates(query, user_id, query))
|
|
60
|
+
hop += 1
|
|
61
|
+
break if hop >= max_hops
|
|
62
|
+
|
|
63
|
+
query = reasoner.call(user_message, accumulated, hop).to_s.strip
|
|
64
|
+
end
|
|
65
|
+
hops_done = hop
|
|
55
66
|
|
|
56
|
-
|
|
67
|
+
final = accumulated.sort_by { |c| -(c[:temporal_score] || c[:score] || 0) }
|
|
57
68
|
end
|
|
58
|
-
|
|
59
|
-
final = accumulated.sort_by { |c| -(c[:temporal_score] || c[:score] || 0) }
|
|
60
69
|
@assembler.assemble(final, max_tokens: max_tokens)
|
|
61
70
|
end
|
|
62
71
|
|