llmemory 0.2.1 → 0.2.3
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 +78 -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 +28 -2
- data/lib/llmemory/crypto/cipher.rb +147 -0
- data/lib/llmemory/crypto/field_helpers.rb +110 -0
- data/lib/llmemory/instrumentation.rb +33 -0
- data/lib/llmemory/llm/anthropic.rb +21 -16
- data/lib/llmemory/llm/openai.rb +18 -13
- data/lib/llmemory/long_term/episodic/memory.rb +27 -13
- data/lib/llmemory/long_term/episodic/storage.rb +11 -4
- data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +33 -10
- data/lib/llmemory/long_term/episodic/storages/base.rb +15 -2
- data/lib/llmemory/long_term/episodic/storages/database_storage.rb +51 -8
- data/lib/llmemory/long_term/episodic/storages/file_storage.rb +47 -9
- 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/storage.rb +11 -4
- data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +20 -12
- data/lib/llmemory/long_term/file_based/storages/base.rb +2 -2
- data/lib/llmemory/long_term/file_based/storages/database_storage.rb +28 -10
- data/lib/llmemory/long_term/file_based/storages/file_storage.rb +32 -16
- data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +4 -2
- data/lib/llmemory/long_term/graph_based/memory.rb +16 -7
- data/lib/llmemory/long_term/graph_based/storage.rb +3 -2
- data/lib/llmemory/long_term/graph_based/storages/active_record_storage.rb +51 -23
- 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 +30 -16
- data/lib/llmemory/long_term/procedural/skill.rb +6 -2
- data/lib/llmemory/long_term/procedural/storage.rb +11 -4
- data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +47 -17
- data/lib/llmemory/long_term/procedural/storages/base.rb +14 -1
- data/lib/llmemory/long_term/procedural/storages/database_storage.rb +52 -10
- data/lib/llmemory/long_term/procedural/storages/file_storage.rb +49 -11
- 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 +60 -8
- 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/short_term/checkpoint.rb +3 -2
- data/lib/llmemory/short_term/stores/active_record_store.rb +12 -10
- data/lib/llmemory/short_term/stores/memory_store.rb +1 -1
- data/lib/llmemory/short_term/stores/postgres_store.rb +11 -5
- data/lib/llmemory/short_term/stores/redis_store.rb +7 -5
- data/lib/llmemory/short_term/stores.rb +7 -6
- data/lib/llmemory/skill_mining/miner.rb +163 -0
- data/lib/llmemory/skill_mining.rb +8 -0
- data/lib/llmemory/vector_store/active_record_store.rb +24 -3
- data/lib/llmemory/vector_store/memory_store.rb +23 -3
- data/lib/llmemory/vector_store/openai_embeddings.rb +11 -7
- data/lib/llmemory/vector_store.rb +4 -3
- data/lib/llmemory/version.rb +1 -1
- data/lib/llmemory.rb +4 -0
- metadata +24 -1
|
@@ -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
|
@@ -10,34 +10,57 @@ 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, episodic: nil, procedural: 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, encryption_key: :inherit)
|
|
14
14
|
@user_id = user_id
|
|
15
15
|
@session_id = session_id
|
|
16
|
-
|
|
16
|
+
resolved_key = encryption_key == :inherit ? nil : encryption_key
|
|
17
|
+
@cipher = Llmemory.build_cipher(resolved_key)
|
|
18
|
+
@checkpoint = checkpoint || ShortTerm::Checkpoint.new(
|
|
19
|
+
user_id: user_id,
|
|
20
|
+
session_id: session_id,
|
|
21
|
+
cipher: @cipher
|
|
22
|
+
)
|
|
17
23
|
@working_memory = working_memory
|
|
18
24
|
@episodic = episodic
|
|
19
25
|
@procedural = procedural
|
|
20
26
|
@llm = api_key.to_s.empty? ? nil : Llmemory::LLM.client(api_key: api_key)
|
|
21
27
|
type = long_term_type || Llmemory.configuration.long_term_type || :file_based
|
|
22
28
|
@long_term = long_term || build_long_term(type)
|
|
23
|
-
|
|
29
|
+
short_term_store = build_short_term_store(@cipher)
|
|
30
|
+
@retrieval_engine = retrieval_engine || Retrieval::Engine.new(
|
|
31
|
+
@long_term,
|
|
32
|
+
llm: @llm,
|
|
33
|
+
feedback: Retrieval::FeedbackStore.new(store: short_term_store)
|
|
34
|
+
)
|
|
24
35
|
end
|
|
25
36
|
|
|
26
37
|
# Structured working memory for this session (CoALA working memory),
|
|
27
38
|
# parallel to the message checkpoint. Lazily built.
|
|
28
39
|
def working_memory
|
|
29
|
-
@working_memory ||= WorkingMemory.new(
|
|
40
|
+
@working_memory ||= WorkingMemory.new(
|
|
41
|
+
user_id: @user_id,
|
|
42
|
+
session_id: @session_id,
|
|
43
|
+
store: build_short_term_store(@cipher)
|
|
44
|
+
)
|
|
30
45
|
end
|
|
31
46
|
|
|
32
47
|
# Episodic long-term memory (CoALA): records and retrieves agent trajectories.
|
|
33
48
|
# Additive — coexists with the semantic store (file/graph). Lazily built.
|
|
34
49
|
def episodic
|
|
35
|
-
@episodic ||= LongTerm::Episodic::Memory.new(
|
|
50
|
+
@episodic ||= LongTerm::Episodic::Memory.new(
|
|
51
|
+
user_id: @user_id,
|
|
52
|
+
storage: LongTerm::Episodic::Storages.build(cipher: @cipher),
|
|
53
|
+
cipher: @cipher
|
|
54
|
+
)
|
|
36
55
|
end
|
|
37
56
|
|
|
38
57
|
# Procedural long-term memory (Voyager-style skill library). Lazily built.
|
|
39
58
|
def procedural
|
|
40
|
-
@procedural ||= LongTerm::Procedural::Memory.new(
|
|
59
|
+
@procedural ||= LongTerm::Procedural::Memory.new(
|
|
60
|
+
user_id: @user_id,
|
|
61
|
+
storage: LongTerm::Procedural::Storages.build(cipher: @cipher),
|
|
62
|
+
cipher: @cipher
|
|
63
|
+
)
|
|
41
64
|
end
|
|
42
65
|
|
|
43
66
|
# Reflects over recent episodes and writes distilled insights to the
|
|
@@ -53,6 +76,26 @@ module Llmemory
|
|
|
53
76
|
Actions::Reason.call(working_memory: working_memory, template: template, into: into, parse: parse, llm: @llm)
|
|
54
77
|
end
|
|
55
78
|
|
|
79
|
+
# Mines recent episodes for reusable skills (Voyager-style). Human-in-the-loop
|
|
80
|
+
# by default: returns skill proposals and writes nothing. With
|
|
81
|
+
# `auto_register: true`, registers them in procedural memory (with provenance
|
|
82
|
+
# back to the source episodes) and returns the new skill ids.
|
|
83
|
+
def mine_skills!(window: SkillMining::Miner::DEFAULT_WINDOW, outcomes: nil, auto_register: false)
|
|
84
|
+
SkillMining::Miner.new(episodic: episodic, procedural: procedural, llm: @llm)
|
|
85
|
+
.mine(window: window, outcomes: outcomes, auto_register: auto_register)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Cognitive maintenance pass: consolidate -> reflect -> mine skills -> expire,
|
|
89
|
+
# in one step, closing the CoALA learning loop. Each step is isolated; a
|
|
90
|
+
# failure in one is captured in the report and never aborts the others.
|
|
91
|
+
def maintain!(**opts)
|
|
92
|
+
Maintenance::CognitivePass.run!(
|
|
93
|
+
@user_id,
|
|
94
|
+
memory: self, episodic: episodic, procedural: procedural, semantic: @long_term, llm: @llm,
|
|
95
|
+
**opts
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
56
99
|
def add_message(role:, content:)
|
|
57
100
|
msgs = messages
|
|
58
101
|
msgs << { role: role.to_sym, content: content.to_s }
|
|
@@ -301,14 +344,23 @@ module Llmemory
|
|
|
301
344
|
when :graph_based
|
|
302
345
|
LongTerm::GraphBased::Memory.new(
|
|
303
346
|
user_id: @user_id,
|
|
304
|
-
storage: LongTerm::GraphBased::Storages.build,
|
|
347
|
+
storage: LongTerm::GraphBased::Storages.build(cipher: @cipher),
|
|
348
|
+
cipher: @cipher,
|
|
305
349
|
**llm_opts
|
|
306
350
|
)
|
|
307
351
|
else
|
|
308
|
-
LongTerm::FileBased::Memory.new(
|
|
352
|
+
LongTerm::FileBased::Memory.new(
|
|
353
|
+
user_id: @user_id,
|
|
354
|
+
storage: LongTerm::FileBased::Storages.build(cipher: @cipher),
|
|
355
|
+
**llm_opts
|
|
356
|
+
)
|
|
309
357
|
end
|
|
310
358
|
end
|
|
311
359
|
|
|
360
|
+
def build_short_term_store(cipher)
|
|
361
|
+
ShortTerm::Stores.build(cipher: cipher)
|
|
362
|
+
end
|
|
363
|
+
|
|
312
364
|
def save_state(messages:, last_flush_at: nil, last_compact_at: nil)
|
|
313
365
|
state = { STATE_KEY_MESSAGES => messages, last_activity_at: Time.now }
|
|
314
366
|
state[:last_flush_at] = last_flush_at if last_flush_at
|
|
@@ -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
|
|
|
@@ -7,9 +7,10 @@ module Llmemory
|
|
|
7
7
|
class Checkpoint
|
|
8
8
|
DEFAULT_SESSION_ID = "default"
|
|
9
9
|
|
|
10
|
-
def initialize(user_id:, session_id: DEFAULT_SESSION_ID, store: nil)
|
|
10
|
+
def initialize(user_id:, session_id: DEFAULT_SESSION_ID, store: nil, cipher: nil)
|
|
11
11
|
@user_id = user_id
|
|
12
12
|
@session_id = session_id
|
|
13
|
+
@cipher = cipher
|
|
13
14
|
@store = store || build_store
|
|
14
15
|
end
|
|
15
16
|
|
|
@@ -28,7 +29,7 @@ module Llmemory
|
|
|
28
29
|
private
|
|
29
30
|
|
|
30
31
|
def build_store
|
|
31
|
-
Stores.build
|
|
32
|
+
Stores.build(cipher: @cipher)
|
|
32
33
|
end
|
|
33
34
|
end
|
|
34
35
|
end
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
|
+
require_relative "../../crypto/field_helpers"
|
|
4
5
|
|
|
5
6
|
module Llmemory
|
|
6
7
|
module ShortTerm
|
|
7
8
|
module Stores
|
|
8
9
|
class ActiveRecordStore < Base
|
|
9
|
-
|
|
10
|
+
include Llmemory::Crypto::FieldHelpers
|
|
11
|
+
|
|
12
|
+
def initialize(cipher: nil)
|
|
13
|
+
@cipher = cipher || Llmemory.build_cipher
|
|
10
14
|
self.class.load_model!
|
|
11
15
|
end
|
|
12
16
|
|
|
@@ -22,7 +26,7 @@ module Llmemory
|
|
|
22
26
|
user_id: user_id,
|
|
23
27
|
session_id: session_id
|
|
24
28
|
)
|
|
25
|
-
record.state = state
|
|
29
|
+
record.state = cipher.enabled? ? serialize_state(state) : state
|
|
26
30
|
record.updated_at = Time.current
|
|
27
31
|
record.save!
|
|
28
32
|
true
|
|
@@ -34,8 +38,13 @@ module Llmemory
|
|
|
34
38
|
session_id: session_id
|
|
35
39
|
)
|
|
36
40
|
return nil unless record
|
|
41
|
+
|
|
37
42
|
raw = record.state
|
|
38
|
-
raw.is_a?(Hash)
|
|
43
|
+
if raw.is_a?(Hash)
|
|
44
|
+
raw.transform_keys(&:to_sym)
|
|
45
|
+
else
|
|
46
|
+
deserialize_state(raw)
|
|
47
|
+
end
|
|
39
48
|
end
|
|
40
49
|
|
|
41
50
|
def delete(user_id, session_id)
|
|
@@ -53,13 +62,6 @@ module Llmemory
|
|
|
53
62
|
def list_sessions(user_id:)
|
|
54
63
|
Llmemory::ShortTerm::Stores::ActiveRecordCheckpoint.where(user_id: user_id).pluck(:session_id)
|
|
55
64
|
end
|
|
56
|
-
|
|
57
|
-
private
|
|
58
|
-
|
|
59
|
-
def deserialize(data)
|
|
60
|
-
return data if data.is_a?(Hash)
|
|
61
|
-
JSON.parse(data.to_s, symbolize_names: true)
|
|
62
|
-
end
|
|
63
65
|
end
|
|
64
66
|
end
|
|
65
67
|
end
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
|
+
require_relative "../../crypto/field_helpers"
|
|
4
5
|
|
|
5
6
|
module Llmemory
|
|
6
7
|
module ShortTerm
|
|
7
8
|
module Stores
|
|
8
9
|
class PostgresStore < Base
|
|
9
|
-
|
|
10
|
+
include Llmemory::Crypto::FieldHelpers
|
|
11
|
+
|
|
12
|
+
def initialize(database_url: nil, cipher: nil)
|
|
10
13
|
@database_url = database_url || Llmemory.configuration.database_url
|
|
11
14
|
@connection = nil
|
|
15
|
+
@cipher = cipher || Llmemory.build_cipher
|
|
12
16
|
end
|
|
13
17
|
|
|
14
18
|
def save(user_id, session_id, state)
|
|
@@ -81,13 +85,15 @@ module Llmemory
|
|
|
81
85
|
end
|
|
82
86
|
|
|
83
87
|
def serialize(state)
|
|
84
|
-
|
|
85
|
-
JSON.generate(
|
|
88
|
+
payload = serialize_state(state)
|
|
89
|
+
cipher.enabled? ? JSON.generate(payload) : payload
|
|
86
90
|
end
|
|
87
91
|
|
|
88
92
|
def deserialize(data)
|
|
89
|
-
|
|
90
|
-
|
|
93
|
+
if data.is_a?(String) && !cipher.encrypted?(data)
|
|
94
|
+
data = JSON.parse(data)
|
|
95
|
+
end
|
|
96
|
+
deserialize_state(data)
|
|
91
97
|
end
|
|
92
98
|
end
|
|
93
99
|
end
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
|
+
require_relative "../../crypto/field_helpers"
|
|
4
5
|
|
|
5
6
|
module Llmemory
|
|
6
7
|
module ShortTerm
|
|
7
8
|
module Stores
|
|
8
9
|
class RedisStore < Base
|
|
9
|
-
|
|
10
|
+
include Llmemory::Crypto::FieldHelpers
|
|
11
|
+
|
|
12
|
+
def initialize(redis_url: nil, cipher: nil)
|
|
10
13
|
@redis_url = redis_url || Llmemory.configuration.redis_url
|
|
11
14
|
@redis = nil
|
|
15
|
+
@cipher = cipher || Llmemory.build_cipher
|
|
12
16
|
end
|
|
13
17
|
|
|
14
18
|
def save(user_id, session_id, state)
|
|
@@ -50,13 +54,11 @@ module Llmemory
|
|
|
50
54
|
end
|
|
51
55
|
|
|
52
56
|
def serialize(state)
|
|
53
|
-
|
|
54
|
-
JSON.generate(state)
|
|
57
|
+
serialize_state(state)
|
|
55
58
|
end
|
|
56
59
|
|
|
57
60
|
def deserialize(data)
|
|
58
|
-
|
|
59
|
-
JSON.parse(data, symbolize_names: true)
|
|
61
|
+
deserialize_state(data)
|
|
60
62
|
end
|
|
61
63
|
end
|
|
62
64
|
end
|
|
@@ -10,16 +10,17 @@ module Llmemory
|
|
|
10
10
|
module Stores
|
|
11
11
|
# Single source of truth for selecting a short-term store backend.
|
|
12
12
|
# Shared by Checkpoint, SessionLifecycle and WorkingMemory.
|
|
13
|
-
def self.build(store_type = nil)
|
|
13
|
+
def self.build(store_type = nil, cipher: nil)
|
|
14
|
+
resolved_cipher = cipher || Llmemory.build_cipher
|
|
14
15
|
case (store_type || Llmemory.configuration.short_term_store).to_sym
|
|
15
|
-
when :memory then MemoryStore.new
|
|
16
|
-
when :redis then RedisStore.new
|
|
17
|
-
when :postgres then PostgresStore.new
|
|
16
|
+
when :memory then MemoryStore.new(cipher: resolved_cipher)
|
|
17
|
+
when :redis then RedisStore.new(cipher: resolved_cipher)
|
|
18
|
+
when :postgres then PostgresStore.new(cipher: resolved_cipher)
|
|
18
19
|
when :active_record, :activerecord
|
|
19
20
|
require_relative "stores/active_record_store"
|
|
20
|
-
ActiveRecordStore.new
|
|
21
|
+
ActiveRecordStore.new(cipher: resolved_cipher)
|
|
21
22
|
else
|
|
22
|
-
MemoryStore.new
|
|
23
|
+
MemoryStore.new(cipher: resolved_cipher)
|
|
23
24
|
end
|
|
24
25
|
end
|
|
25
26
|
end
|