llmemory 0.2.1 → 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 +47 -1
- 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 +2 -0
- data/lib/llmemory/cli/commands/maintain.rb +62 -0
- data/lib/llmemory/cli/commands/mine_skills.rb +50 -0
- data/lib/llmemory/cli.rb +6 -0
- data/lib/llmemory/configuration.rb +7 -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 +23 -10
- data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +14 -4
- data/lib/llmemory/long_term/episodic/storages/base.rb +15 -2
- data/lib/llmemory/long_term/episodic/storages/database_storage.rb +26 -5
- data/lib/llmemory/long_term/episodic/storages/file_storage.rb +27 -6
- data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +35 -4
- data/lib/llmemory/long_term/file_based/memory.rb +12 -4
- data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +4 -2
- data/lib/llmemory/long_term/file_based/storages/base.rb +2 -2
- data/lib/llmemory/long_term/file_based/storages/database_storage.rb +4 -2
- data/lib/llmemory/long_term/file_based/storages/file_storage.rb +4 -2
- data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +4 -2
- data/lib/llmemory/long_term/graph_based/memory.rb +12 -4
- 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 +26 -13
- data/lib/llmemory/long_term/procedural/skill.rb +6 -2
- data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +15 -5
- data/lib/llmemory/long_term/procedural/storages/base.rb +14 -1
- data/lib/llmemory/long_term/procedural/storages/database_storage.rb +27 -6
- data/lib/llmemory/long_term/procedural/storages/file_storage.rb +28 -7
- data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +36 -5
- 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 +5 -1
- data/lib/llmemory/mcp/tools/memory_maintain.rb +53 -0
- data/lib/llmemory/mcp/tools/memory_mine_skills.rb +53 -0
- data/lib/llmemory/memory.rb +20 -0
- data/lib/llmemory/memory_module.rb +13 -6
- data/lib/llmemory/reflection/reflector.rb +24 -20
- data/lib/llmemory/retrieval/engine.rb +25 -16
- data/lib/llmemory/skill_mining/miner.rb +163 -0
- data/lib/llmemory/skill_mining.rb +8 -0
- data/lib/llmemory/vector_store/openai_embeddings.rb +11 -7
- data/lib/llmemory/version.rb +1 -1
- data/lib/llmemory.rb +2 -0
- metadata +22 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module Maintenance
|
|
5
|
+
# The cognitive maintenance pass closes CoALA's learning loop in one
|
|
6
|
+
# scheduled step. Independently, the gem exposes consolidation (short-term ->
|
|
7
|
+
# semantic), reflection (episodic -> insights), skill mining (episodic ->
|
|
8
|
+
# procedural) and TTL expiry. This pass orchestrates them so an agent learns
|
|
9
|
+
# from its experience and keeps its memory healthy without the consumer
|
|
10
|
+
# wiring each step by hand.
|
|
11
|
+
#
|
|
12
|
+
# Designed to run as a maintenance task (cron / Rails Job), per user. Each
|
|
13
|
+
# step is isolated: a failure in one is captured in the returned report
|
|
14
|
+
# (`:errors`) and never aborts the others.
|
|
15
|
+
#
|
|
16
|
+
# Returns:
|
|
17
|
+
# {
|
|
18
|
+
# consolidated: true/false/nil, # nil when no `memory:` was supplied
|
|
19
|
+
# insights: [insight_id, ...],
|
|
20
|
+
# mined: [proposal_or_skill_id, ...],
|
|
21
|
+
# expired: { episodic: N, procedural: M },
|
|
22
|
+
# errors: { reflect: "...", mine: "...", ... } # only failed steps
|
|
23
|
+
# }
|
|
24
|
+
class CognitivePass
|
|
25
|
+
def self.run!(user_id, **kwargs)
|
|
26
|
+
new(user_id, **kwargs).run!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(user_id, memory: nil, episodic: nil, procedural: nil, semantic: nil,
|
|
30
|
+
llm: nil, reflect: true, mine_skills: nil, expire: true,
|
|
31
|
+
reflection_window: 10, mining_window: Llmemory::SkillMining::Miner::DEFAULT_WINDOW)
|
|
32
|
+
@user_id = user_id
|
|
33
|
+
@memory = memory
|
|
34
|
+
@episodic = episodic
|
|
35
|
+
@procedural = procedural
|
|
36
|
+
@semantic = semantic
|
|
37
|
+
@llm = llm
|
|
38
|
+
@reflect = reflect
|
|
39
|
+
@mine_skills = mine_skills.nil? ? Llmemory.configuration.skill_mining_enabled : mine_skills
|
|
40
|
+
@expire = expire
|
|
41
|
+
@reflection_window = reflection_window
|
|
42
|
+
@mining_window = mining_window
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def run!
|
|
46
|
+
report = { consolidated: nil, insights: [], mined: [], expired: { episodic: 0, procedural: 0 }, errors: {} }
|
|
47
|
+
|
|
48
|
+
step(report, :consolidate) { report[:consolidated] = consolidate } if @memory
|
|
49
|
+
step(report, :reflect) { report[:insights] = reflect } if @reflect
|
|
50
|
+
step(report, :mine) { report[:mined] = mine } if @mine_skills
|
|
51
|
+
step(report, :expire) { report[:expired] = expire } if @expire
|
|
52
|
+
|
|
53
|
+
report
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def step(report, name)
|
|
59
|
+
yield
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
report[:errors][name] = e.message
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def consolidate
|
|
65
|
+
@memory.consolidate!
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def reflect
|
|
69
|
+
Reflection::Reflector.new(episodic: episodic, semantic: semantic, llm: @llm)
|
|
70
|
+
.reflect(window: @reflection_window)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def mine
|
|
74
|
+
SkillMining::Miner.new(episodic: episodic, procedural: procedural, llm: @llm)
|
|
75
|
+
.mine(window: @mining_window, auto_register: true)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def expire
|
|
79
|
+
TTLExpiry.run!(@user_id, episodic: episodic, procedural: procedural)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def episodic
|
|
83
|
+
@episodic ||= @memory&.episodic || Llmemory::LongTerm::Episodic::Memory.new(user_id: @user_id)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def procedural
|
|
87
|
+
@procedural ||= @memory&.procedural || Llmemory::LongTerm::Procedural::Memory.new(user_id: @user_id)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def semantic
|
|
91
|
+
@semantic ||= build_semantic
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def build_semantic
|
|
95
|
+
llm_opts = @llm ? { llm: @llm } : {}
|
|
96
|
+
case (Llmemory.configuration.long_term_type || :file_based).to_s.to_sym
|
|
97
|
+
when :graph_based
|
|
98
|
+
Llmemory::LongTerm::GraphBased::Memory.new(
|
|
99
|
+
user_id: @user_id, storage: Llmemory::LongTerm::GraphBased::Storages.build, **llm_opts
|
|
100
|
+
)
|
|
101
|
+
else
|
|
102
|
+
Llmemory::LongTerm::FileBased::Memory.new(
|
|
103
|
+
user_id: @user_id, storage: Llmemory::LongTerm::FileBased::Storages.build, **llm_opts
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module Maintenance
|
|
5
|
+
# TTL expiry job: soft-archives episodic/procedural entries whose age
|
|
6
|
+
# exceeds the configured per-type TTL. Designed to run as a maintenance
|
|
7
|
+
# task (cron / Rails Job). Idempotent — already-archived entries are
|
|
8
|
+
# skipped by the storage layer.
|
|
9
|
+
#
|
|
10
|
+
# Reads `Llmemory.configuration.ttl_episodic_days` and
|
|
11
|
+
# `Llmemory.configuration.ttl_procedural_days`. A nil/zero TTL disables
|
|
12
|
+
# expiry for that memory type.
|
|
13
|
+
#
|
|
14
|
+
# Returns a hash `{ episodic: N, procedural: M }` with the number of
|
|
15
|
+
# entries archived per type for the given user.
|
|
16
|
+
class TTLExpiry
|
|
17
|
+
DEFAULT_REASON = "ttl_expired"
|
|
18
|
+
|
|
19
|
+
def self.run!(user_id, episodic: nil, procedural: nil, reason: DEFAULT_REASON)
|
|
20
|
+
new(user_id, episodic: episodic, procedural: procedural, reason: reason).run!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(user_id, episodic: nil, procedural: nil, reason: DEFAULT_REASON)
|
|
24
|
+
@user_id = user_id
|
|
25
|
+
@episodic = episodic
|
|
26
|
+
@procedural = procedural
|
|
27
|
+
@reason = reason
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run!
|
|
31
|
+
{
|
|
32
|
+
episodic: expire(memory: @episodic ||= Llmemory::LongTerm::Episodic::Memory.new(user_id: @user_id),
|
|
33
|
+
ttl_days: Llmemory.configuration.ttl_episodic_days),
|
|
34
|
+
procedural: expire(memory: @procedural ||= Llmemory::LongTerm::Procedural::Memory.new(user_id: @user_id),
|
|
35
|
+
ttl_days: Llmemory.configuration.ttl_procedural_days)
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def expire(memory:, ttl_days:)
|
|
42
|
+
return 0 unless ttl_days && ttl_days.to_f.positive?
|
|
43
|
+
cutoff = Time.now - (ttl_days.to_f * 86400)
|
|
44
|
+
ids = memory.expired_ids(cutoff: cutoff)
|
|
45
|
+
return 0 if ids.empty?
|
|
46
|
+
memory.forget(ids: ids, reason: @reason, mode: :soft)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/llmemory/maintenance.rb
CHANGED
data/lib/llmemory/mcp/server.rb
CHANGED
|
@@ -17,6 +17,8 @@ require_relative "tools/memory_skill_register"
|
|
|
17
17
|
require_relative "tools/memory_skill_report"
|
|
18
18
|
require_relative "tools/memory_skills"
|
|
19
19
|
require_relative "tools/memory_forget"
|
|
20
|
+
require_relative "tools/memory_mine_skills"
|
|
21
|
+
require_relative "tools/memory_maintain"
|
|
20
22
|
|
|
21
23
|
module Llmemory
|
|
22
24
|
module MCP
|
|
@@ -169,7 +171,9 @@ module Llmemory
|
|
|
169
171
|
Tools::MemorySkillRegister,
|
|
170
172
|
Tools::MemorySkillReport,
|
|
171
173
|
Tools::MemorySkills,
|
|
172
|
-
Tools::MemoryForget
|
|
174
|
+
Tools::MemoryForget,
|
|
175
|
+
Tools::MemoryMineSkills,
|
|
176
|
+
Tools::MemoryMaintain
|
|
173
177
|
]
|
|
174
178
|
end
|
|
175
179
|
|
|
@@ -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
|
data/lib/llmemory/memory.rb
CHANGED
|
@@ -53,6 +53,26 @@ module Llmemory
|
|
|
53
53
|
Actions::Reason.call(working_memory: working_memory, template: template, into: into, parse: parse, llm: @llm)
|
|
54
54
|
end
|
|
55
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
|
+
|
|
56
76
|
def add_message(role:, content:)
|
|
57
77
|
msgs = messages
|
|
58
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
|
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module SkillMining
|
|
7
|
+
# Skill mining scans an agent's recent episodes (episodic memory) for
|
|
8
|
+
# repeated, successful trajectories and distills them into reusable skills
|
|
9
|
+
# (procedural memory). This is Voyager's actual contribution: rather than a
|
|
10
|
+
# passive, hand-written skill library, procedural memory grows from lived
|
|
11
|
+
# experience.
|
|
12
|
+
#
|
|
13
|
+
# Mining is human-in-the-loop by default: `mine` returns skill *proposals*
|
|
14
|
+
# and writes nothing. Pass `auto_register: true` to register them directly.
|
|
15
|
+
# Each registered skill carries provenance { method: "skill_mining",
|
|
16
|
+
# sources: [{ type: "episode", id: ... }] } so it stays traceable to the
|
|
17
|
+
# experiences it was distilled from.
|
|
18
|
+
#
|
|
19
|
+
# `procedural` must respond to:
|
|
20
|
+
# register_skill(name:, body:, description:, kind:, provenance:)
|
|
21
|
+
class Miner
|
|
22
|
+
DEFAULT_WINDOW = 20
|
|
23
|
+
DEFAULT_CONFIDENCE = 0.5
|
|
24
|
+
VALID_KINDS = %w[prompt template code].freeze
|
|
25
|
+
|
|
26
|
+
def initialize(episodic:, procedural:, llm: nil)
|
|
27
|
+
@episodic = episodic
|
|
28
|
+
@procedural = procedural
|
|
29
|
+
@llm = llm || Llmemory::LLM.client
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Mines the most recent `window` episodes for reusable skills. When
|
|
33
|
+
# `outcomes` (an allowlist of outcome labels) is given, only episodes whose
|
|
34
|
+
# outcome is in the set are considered — a deterministic pre-filter.
|
|
35
|
+
#
|
|
36
|
+
# Returns an array of proposal hashes
|
|
37
|
+
# ({ name:, kind:, body:, description:, confidence: }). When
|
|
38
|
+
# `auto_register: true`, registers each proposal and returns the new skill
|
|
39
|
+
# ids instead.
|
|
40
|
+
def mine(window: DEFAULT_WINDOW, outcomes: nil, auto_register: false)
|
|
41
|
+
result = []
|
|
42
|
+
Llmemory::Instrumentation.instrument(:mine_skills, window: window, auto_register: auto_register) do
|
|
43
|
+
episodes = @episodic.recent_episodes(limit: window)
|
|
44
|
+
episodes = filter_by_outcome(episodes, outcomes) if outcomes
|
|
45
|
+
next if episodes.empty?
|
|
46
|
+
|
|
47
|
+
proposals = distill(episodes)
|
|
48
|
+
next if proposals.empty?
|
|
49
|
+
|
|
50
|
+
result = auto_register ? register(proposals, episodes) : proposals
|
|
51
|
+
end
|
|
52
|
+
result
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def filter_by_outcome(episodes, outcomes)
|
|
58
|
+
allowed = Array(outcomes).map { |o| o.to_s.strip.downcase }
|
|
59
|
+
episodes.select { |ep| allowed.include?(ep.outcome.to_s.strip.downcase) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def register(proposals, episodes)
|
|
63
|
+
sources = episodes.map(&:id).compact.map { |id| { type: "episode", id: id } }
|
|
64
|
+
proposals.map do |p|
|
|
65
|
+
provenance = Llmemory::Provenance.build(
|
|
66
|
+
method: "skill_mining",
|
|
67
|
+
sources: sources,
|
|
68
|
+
confidence: p[:confidence]
|
|
69
|
+
)
|
|
70
|
+
@procedural.register_skill(
|
|
71
|
+
name: p[:name],
|
|
72
|
+
body: p[:body],
|
|
73
|
+
description: p[:description],
|
|
74
|
+
kind: p[:kind],
|
|
75
|
+
provenance: provenance
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def distill(episodes)
|
|
81
|
+
response = @llm.invoke(build_prompt(episodes))
|
|
82
|
+
parse_proposals(response)
|
|
83
|
+
rescue Llmemory::LLMError
|
|
84
|
+
[]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def build_prompt(episodes)
|
|
88
|
+
episodes_text = episodes.each_with_index.map do |ep, i|
|
|
89
|
+
"Episode #{i + 1} (outcome: #{ep.outcome || 'n/a'}):\n#{ep.searchable_text}"
|
|
90
|
+
end.join("\n\n")
|
|
91
|
+
|
|
92
|
+
<<~PROMPT
|
|
93
|
+
You are mining an agent's recent experiences for reusable skills. A skill
|
|
94
|
+
is a repeatable procedure the agent can apply again: a prompt, a template,
|
|
95
|
+
or a snippet of code. Only propose a skill when you see a SUCCESSFUL
|
|
96
|
+
pattern that recurs across episodes — generalize the steps into a reusable
|
|
97
|
+
procedure. Do not propose one-off actions or failures.
|
|
98
|
+
|
|
99
|
+
Recent episodes:
|
|
100
|
+
#{episodes_text}
|
|
101
|
+
|
|
102
|
+
Return a JSON array of objects with keys:
|
|
103
|
+
"name" (short snake_case identifier),
|
|
104
|
+
"kind" (one of "prompt", "template", "code"),
|
|
105
|
+
"body" (the reusable procedure itself),
|
|
106
|
+
"description" (one sentence on when to apply it),
|
|
107
|
+
"confidence" (0-1).
|
|
108
|
+
Return an empty array if no reusable skill can be distilled.
|
|
109
|
+
Example: [{"name": "rollback_on_deploy_failure", "kind": "prompt",
|
|
110
|
+
"body": "When a deploy fails, roll back to the last known-good release.",
|
|
111
|
+
"description": "Recover service after a failed deploy", "confidence": 0.8}]
|
|
112
|
+
PROMPT
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def parse_proposals(response)
|
|
116
|
+
json = extract_json_array(response)
|
|
117
|
+
return [] unless json
|
|
118
|
+
|
|
119
|
+
json.filter_map do |item|
|
|
120
|
+
next nil unless item.is_a?(Hash)
|
|
121
|
+
name = (item["name"] || item[:name]).to_s.strip
|
|
122
|
+
body = (item["body"] || item[:body]).to_s.strip
|
|
123
|
+
next nil if name.empty? || body.empty?
|
|
124
|
+
|
|
125
|
+
{
|
|
126
|
+
name: name,
|
|
127
|
+
kind: normalize_kind(item["kind"] || item[:kind]),
|
|
128
|
+
body: body,
|
|
129
|
+
description: presence(item["description"] || item[:description]),
|
|
130
|
+
confidence: normalize_confidence(item["confidence"] || item[:confidence])
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def normalize_kind(value)
|
|
136
|
+
k = value.to_s.strip.downcase
|
|
137
|
+
VALID_KINDS.include?(k) ? k : "prompt"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def normalize_confidence(value)
|
|
141
|
+
return DEFAULT_CONFIDENCE if value.nil?
|
|
142
|
+
v = value.to_f
|
|
143
|
+
v.between?(0, 1) ? v : DEFAULT_CONFIDENCE
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def presence(value)
|
|
147
|
+
s = value.to_s.strip
|
|
148
|
+
s.empty? ? nil : s
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def extract_json_array(response)
|
|
152
|
+
response = response.to_s.strip
|
|
153
|
+
start_idx = response.index("[")
|
|
154
|
+
end_idx = response.rindex("]")
|
|
155
|
+
return nil unless start_idx && end_idx
|
|
156
|
+
|
|
157
|
+
JSON.parse(response[start_idx..end_idx])
|
|
158
|
+
rescue JSON::ParserError
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|