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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +78 -1
  3. data/app/controllers/llmemory/dashboard/application_controller.rb +15 -1
  4. data/app/controllers/llmemory/dashboard/episodic_controller.rb +22 -0
  5. data/app/controllers/llmemory/dashboard/forget_log_controller.rb +12 -0
  6. data/app/controllers/llmemory/dashboard/maintenance_controller.rb +92 -0
  7. data/app/controllers/llmemory/dashboard/procedural_controller.rb +22 -0
  8. data/app/controllers/llmemory/dashboard/reflection_controller.rb +37 -0
  9. data/app/controllers/llmemory/dashboard/working_controller.rb +14 -0
  10. data/app/views/llmemory/dashboard/episodic/index.html.erb +37 -0
  11. data/app/views/llmemory/dashboard/forget_log/show.html.erb +23 -0
  12. data/app/views/llmemory/dashboard/maintenance/show.html.erb +65 -0
  13. data/app/views/llmemory/dashboard/procedural/index.html.erb +38 -0
  14. data/app/views/llmemory/dashboard/reflection/show.html.erb +29 -0
  15. data/app/views/llmemory/dashboard/users/show.html.erb +16 -0
  16. data/app/views/llmemory/dashboard/working/show.html.erb +20 -0
  17. data/config/routes.rb +14 -0
  18. data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +2 -0
  19. data/lib/llmemory/cli/commands/maintain.rb +62 -0
  20. data/lib/llmemory/cli/commands/mine_skills.rb +50 -0
  21. data/lib/llmemory/cli.rb +6 -0
  22. data/lib/llmemory/configuration.rb +28 -2
  23. data/lib/llmemory/crypto/cipher.rb +147 -0
  24. data/lib/llmemory/crypto/field_helpers.rb +110 -0
  25. data/lib/llmemory/instrumentation.rb +33 -0
  26. data/lib/llmemory/llm/anthropic.rb +21 -16
  27. data/lib/llmemory/llm/openai.rb +18 -13
  28. data/lib/llmemory/long_term/episodic/memory.rb +27 -13
  29. data/lib/llmemory/long_term/episodic/storage.rb +11 -4
  30. data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +33 -10
  31. data/lib/llmemory/long_term/episodic/storages/base.rb +15 -2
  32. data/lib/llmemory/long_term/episodic/storages/database_storage.rb +51 -8
  33. data/lib/llmemory/long_term/episodic/storages/file_storage.rb +47 -9
  34. data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +35 -4
  35. data/lib/llmemory/long_term/file_based/memory.rb +12 -4
  36. data/lib/llmemory/long_term/file_based/storage.rb +11 -4
  37. data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +20 -12
  38. data/lib/llmemory/long_term/file_based/storages/base.rb +2 -2
  39. data/lib/llmemory/long_term/file_based/storages/database_storage.rb +28 -10
  40. data/lib/llmemory/long_term/file_based/storages/file_storage.rb +32 -16
  41. data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +4 -2
  42. data/lib/llmemory/long_term/graph_based/memory.rb +16 -7
  43. data/lib/llmemory/long_term/graph_based/storage.rb +3 -2
  44. data/lib/llmemory/long_term/graph_based/storages/active_record_storage.rb +51 -23
  45. data/lib/llmemory/long_term/graph_based/storages/base.rb +2 -2
  46. data/lib/llmemory/long_term/graph_based/storages/memory_storage.rb +4 -2
  47. data/lib/llmemory/long_term/procedural/memory.rb +30 -16
  48. data/lib/llmemory/long_term/procedural/skill.rb +6 -2
  49. data/lib/llmemory/long_term/procedural/storage.rb +11 -4
  50. data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +47 -17
  51. data/lib/llmemory/long_term/procedural/storages/base.rb +14 -1
  52. data/lib/llmemory/long_term/procedural/storages/database_storage.rb +52 -10
  53. data/lib/llmemory/long_term/procedural/storages/file_storage.rb +49 -11
  54. data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +36 -5
  55. data/lib/llmemory/maintenance/cognitive_pass.rb +109 -0
  56. data/lib/llmemory/maintenance/ttl_expiry.rb +50 -0
  57. data/lib/llmemory/maintenance.rb +2 -0
  58. data/lib/llmemory/mcp/server.rb +5 -1
  59. data/lib/llmemory/mcp/tools/memory_maintain.rb +53 -0
  60. data/lib/llmemory/mcp/tools/memory_mine_skills.rb +53 -0
  61. data/lib/llmemory/memory.rb +60 -8
  62. data/lib/llmemory/memory_module.rb +13 -6
  63. data/lib/llmemory/reflection/reflector.rb +24 -20
  64. data/lib/llmemory/retrieval/engine.rb +25 -16
  65. data/lib/llmemory/short_term/checkpoint.rb +3 -2
  66. data/lib/llmemory/short_term/stores/active_record_store.rb +12 -10
  67. data/lib/llmemory/short_term/stores/memory_store.rb +1 -1
  68. data/lib/llmemory/short_term/stores/postgres_store.rb +11 -5
  69. data/lib/llmemory/short_term/stores/redis_store.rb +7 -5
  70. data/lib/llmemory/short_term/stores.rb +7 -6
  71. data/lib/llmemory/skill_mining/miner.rb +163 -0
  72. data/lib/llmemory/skill_mining.rb +8 -0
  73. data/lib/llmemory/vector_store/active_record_store.rb +24 -3
  74. data/lib/llmemory/vector_store/memory_store.rb +23 -3
  75. data/lib/llmemory/vector_store/openai_embeddings.rb +11 -7
  76. data/lib/llmemory/vector_store.rb +4 -3
  77. data/lib/llmemory/version.rb +1 -1
  78. data/lib/llmemory.rb +4 -0
  79. 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
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "maintenance/runner"
4
+ require_relative "maintenance/ttl_expiry"
5
+ require_relative "maintenance/cognitive_pass"
4
6
 
5
7
  module Llmemory
6
8
  module Maintenance
@@ -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
@@ -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
- @checkpoint = checkpoint || ShortTerm::Checkpoint.new(user_id: user_id, session_id: session_id)
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
- @retrieval_engine = retrieval_engine || Retrieval::Engine.new(@long_term, llm: @llm)
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(user_id: @user_id, session_id: @session_id)
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(user_id: @user_id)
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(user_id: @user_id)
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(user_id: @user_id, storage: LongTerm::FileBased::Storages.build, **llm_opts)
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:) -> relevant entries (retrieval)
10
- # write(payload, ...) -> ingest into the store (learning)
11
- # list(user_id:, limit:) -> enumerate stored entries
12
- # stats(user_id:) -> counts and metadata
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
- episodes = @episodic.recent_episodes(limit: window)
34
- return [] if episodes.empty?
35
-
36
- insights = distill(episodes)
37
- return [] if insights.empty?
38
-
39
- sources = episodes.map(&:id).compact.map { |id| { type: "episode", id: id } }
40
-
41
- insights.filter_map do |insight|
42
- provenance = Llmemory::Provenance.build(
43
- method: "reflection",
44
- sources: sources,
45
- confidence: insight[:confidence]
46
- )
47
- @semantic.remember_fact(
48
- content: insight[:content],
49
- category: category,
50
- importance: insight[:confidence] || DEFAULT_IMPORTANCE,
51
- provenance: provenance
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
- search_query = generate_query(user_message)
28
- ranked = ranked_candidates(search_query, user_id, user_message)
29
- @assembler.assemble(ranked, max_tokens: max_tokens)
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
- query = generate_query(user_message)
46
- seen = []
47
- accumulated = []
48
- hop = 0
49
-
50
- while hop < max_hops && live_query?(query) && !seen.include?(query)
51
- seen << query
52
- accumulated = merge_candidates(accumulated, ranked_candidates(query, user_id, query))
53
- hop += 1
54
- break if hop >= max_hops
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
- query = reasoner.call(user_message, accumulated, hop).to_s.strip
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
- def initialize
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) ? raw.transform_keys(&:to_sym) : deserialize(raw)
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
@@ -6,7 +6,7 @@ module Llmemory
6
6
  module ShortTerm
7
7
  module Stores
8
8
  class MemoryStore < Base
9
- def initialize
9
+ def initialize(cipher: nil)
10
10
  @store = {}
11
11
  end
12
12
 
@@ -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
- def initialize(database_url: nil)
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
- require "json"
85
- JSON.generate(state)
88
+ payload = serialize_state(state)
89
+ cipher.enabled? ? JSON.generate(payload) : payload
86
90
  end
87
91
 
88
92
  def deserialize(data)
89
- require "json"
90
- JSON.parse(data, symbolize_names: true)
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
- def initialize(redis_url: nil)
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
- require "json"
54
- JSON.generate(state)
57
+ serialize_state(state)
55
58
  end
56
59
 
57
60
  def deserialize(data)
58
- require "json"
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