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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +47 -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 +7 -1
  23. data/lib/llmemory/instrumentation.rb +33 -0
  24. data/lib/llmemory/llm/anthropic.rb +19 -15
  25. data/lib/llmemory/llm/openai.rb +16 -12
  26. data/lib/llmemory/long_term/episodic/memory.rb +23 -10
  27. data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +14 -4
  28. data/lib/llmemory/long_term/episodic/storages/base.rb +15 -2
  29. data/lib/llmemory/long_term/episodic/storages/database_storage.rb +26 -5
  30. data/lib/llmemory/long_term/episodic/storages/file_storage.rb +27 -6
  31. data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +35 -4
  32. data/lib/llmemory/long_term/file_based/memory.rb +12 -4
  33. data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +4 -2
  34. data/lib/llmemory/long_term/file_based/storages/base.rb +2 -2
  35. data/lib/llmemory/long_term/file_based/storages/database_storage.rb +4 -2
  36. data/lib/llmemory/long_term/file_based/storages/file_storage.rb +4 -2
  37. data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +4 -2
  38. data/lib/llmemory/long_term/graph_based/memory.rb +12 -4
  39. data/lib/llmemory/long_term/graph_based/storages/active_record_storage.rb +4 -2
  40. data/lib/llmemory/long_term/graph_based/storages/base.rb +2 -2
  41. data/lib/llmemory/long_term/graph_based/storages/memory_storage.rb +4 -2
  42. data/lib/llmemory/long_term/procedural/memory.rb +26 -13
  43. data/lib/llmemory/long_term/procedural/skill.rb +6 -2
  44. data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +15 -5
  45. data/lib/llmemory/long_term/procedural/storages/base.rb +14 -1
  46. data/lib/llmemory/long_term/procedural/storages/database_storage.rb +27 -6
  47. data/lib/llmemory/long_term/procedural/storages/file_storage.rb +28 -7
  48. data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +36 -5
  49. data/lib/llmemory/maintenance/cognitive_pass.rb +109 -0
  50. data/lib/llmemory/maintenance/ttl_expiry.rb +50 -0
  51. data/lib/llmemory/maintenance.rb +2 -0
  52. data/lib/llmemory/mcp/server.rb +5 -1
  53. data/lib/llmemory/mcp/tools/memory_maintain.rb +53 -0
  54. data/lib/llmemory/mcp/tools/memory_mine_skills.rb +53 -0
  55. data/lib/llmemory/memory.rb +20 -0
  56. data/lib/llmemory/memory_module.rb +13 -6
  57. data/lib/llmemory/reflection/reflector.rb +24 -20
  58. data/lib/llmemory/retrieval/engine.rb +25 -16
  59. data/lib/llmemory/skill_mining/miner.rb +163 -0
  60. data/lib/llmemory/skill_mining.rb +8 -0
  61. data/lib/llmemory/vector_store/openai_embeddings.rb +11 -7
  62. data/lib/llmemory/version.rb +1 -1
  63. data/lib/llmemory.rb +2 -0
  64. 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
@@ -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
@@ -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:) -> 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
 
@@ -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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "skill_mining/miner"
4
+
5
+ module Llmemory
6
+ module SkillMining
7
+ end
8
+ end