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.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +54 -3
  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 +22 -0
  19. data/lib/llmemory/cli/commands/base.rb +8 -0
  20. data/lib/llmemory/cli/commands/episodic.rb +42 -0
  21. data/lib/llmemory/cli/commands/forget_log.rb +36 -0
  22. data/lib/llmemory/cli/commands/maintain.rb +62 -0
  23. data/lib/llmemory/cli/commands/mine_skills.rb +50 -0
  24. data/lib/llmemory/cli/commands/procedural.rb +44 -0
  25. data/lib/llmemory/cli/commands/working.rb +31 -0
  26. data/lib/llmemory/cli.rb +18 -0
  27. data/lib/llmemory/configuration.rb +11 -1
  28. data/lib/llmemory/instrumentation.rb +33 -0
  29. data/lib/llmemory/llm/anthropic.rb +19 -15
  30. data/lib/llmemory/llm/openai.rb +16 -12
  31. data/lib/llmemory/long_term/episodic/memory.rb +94 -26
  32. data/lib/llmemory/long_term/episodic/storage.rb +7 -5
  33. data/lib/llmemory/long_term/episodic/storages/active_record_models.rb +17 -0
  34. data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +103 -0
  35. data/lib/llmemory/long_term/episodic/storages/base.rb +15 -2
  36. data/lib/llmemory/long_term/episodic/storages/database_storage.rb +156 -0
  37. data/lib/llmemory/long_term/episodic/storages/file_storage.rb +28 -8
  38. data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +36 -6
  39. data/lib/llmemory/long_term/file_based/memory.rb +12 -4
  40. data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +15 -6
  41. data/lib/llmemory/long_term/file_based/storages/base.rb +2 -2
  42. data/lib/llmemory/long_term/file_based/storages/database_storage.rb +20 -8
  43. data/lib/llmemory/long_term/file_based/storages/file_storage.rb +6 -6
  44. data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +6 -6
  45. data/lib/llmemory/long_term/graph_based/memory.rb +89 -64
  46. data/lib/llmemory/long_term/graph_based/storages/active_record_storage.rb +4 -2
  47. data/lib/llmemory/long_term/graph_based/storages/base.rb +2 -2
  48. data/lib/llmemory/long_term/graph_based/storages/memory_storage.rb +4 -2
  49. data/lib/llmemory/long_term/procedural/memory.rb +97 -30
  50. data/lib/llmemory/long_term/procedural/skill.rb +6 -2
  51. data/lib/llmemory/long_term/procedural/storage.rb +7 -5
  52. data/lib/llmemory/long_term/procedural/storages/active_record_models.rb +17 -0
  53. data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +114 -0
  54. data/lib/llmemory/long_term/procedural/storages/base.rb +14 -1
  55. data/lib/llmemory/long_term/procedural/storages/database_storage.rb +169 -0
  56. data/lib/llmemory/long_term/procedural/storages/file_storage.rb +29 -9
  57. data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +37 -7
  58. data/lib/llmemory/maintenance/cognitive_pass.rb +109 -0
  59. data/lib/llmemory/maintenance/ttl_expiry.rb +50 -0
  60. data/lib/llmemory/maintenance.rb +2 -0
  61. data/lib/llmemory/mcp/server.rb +17 -1
  62. data/lib/llmemory/mcp/tools/memory_episode_record.rb +48 -0
  63. data/lib/llmemory/mcp/tools/memory_episodes.rb +43 -0
  64. data/lib/llmemory/mcp/tools/memory_forget.rb +53 -0
  65. data/lib/llmemory/mcp/tools/memory_maintain.rb +53 -0
  66. data/lib/llmemory/mcp/tools/memory_mine_skills.rb +53 -0
  67. data/lib/llmemory/mcp/tools/memory_retrieve.rb +10 -2
  68. data/lib/llmemory/mcp/tools/memory_skill_register.rb +35 -0
  69. data/lib/llmemory/mcp/tools/memory_skill_report.rb +35 -0
  70. data/lib/llmemory/mcp/tools/memory_skills.rb +43 -0
  71. data/lib/llmemory/memory.rb +48 -3
  72. data/lib/llmemory/memory_module.rb +13 -6
  73. data/lib/llmemory/reflection/reflector.rb +24 -20
  74. data/lib/llmemory/retrieval/bm25_scorer.rb +1 -1
  75. data/lib/llmemory/retrieval/engine.rb +25 -16
  76. data/lib/llmemory/retrieval/mmr_reranker.rb +1 -1
  77. data/lib/llmemory/short_term/session_lifecycle.rb +19 -3
  78. data/lib/llmemory/skill_mining/miner.rb +163 -0
  79. data/lib/llmemory/skill_mining.rb +8 -0
  80. data/lib/llmemory/tokenizer.rb +27 -0
  81. data/lib/llmemory/vector_store/active_record_store.rb +4 -3
  82. data/lib/llmemory/vector_store/openai_embeddings.rb +11 -7
  83. data/lib/llmemory/vector_store.rb +14 -0
  84. data/lib/llmemory/version.rb +1 -1
  85. data/lib/llmemory.rb +3 -0
  86. 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
- # Get timeline context around the first match
63
- top_item = items.first
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
@@ -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 so it costs nothing
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:) -> 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
@@ -53,7 +53,7 @@ module Llmemory
53
53
  private
54
54
 
55
55
  def tokenize(text)
56
- text.to_s.downcase.scan(/\b[a-z0-9]{2,}\b/)
56
+ Llmemory::Tokenizer.tokenize(text)
57
57
  end
58
58
  end
59
59
  end
@@ -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
 
@@ -49,7 +49,7 @@ module Llmemory
49
49
  end
50
50
 
51
51
  def tokenize(text)
52
- text.downcase.scan(/\b[a-z0-9]{2,}\b/).uniq
52
+ Llmemory::Tokenizer.tokenize(text).uniq
53
53
  end
54
54
  end
55
55
  end