llmemory 0.1.16 → 0.2.0

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +172 -0
  3. data/lib/llmemory/actions/reason.rb +49 -0
  4. data/lib/llmemory/actions.rb +8 -0
  5. data/lib/llmemory/configuration.rb +2 -0
  6. data/lib/llmemory/forget_log.rb +50 -0
  7. data/lib/llmemory/long_term/episodic/episode.rb +94 -0
  8. data/lib/llmemory/long_term/episodic/memory.rb +120 -0
  9. data/lib/llmemory/long_term/episodic/storage.rb +31 -0
  10. data/lib/llmemory/long_term/episodic/storages/base.rb +44 -0
  11. data/lib/llmemory/long_term/episodic/storages/file_storage.rb +126 -0
  12. data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +74 -0
  13. data/lib/llmemory/long_term/episodic.rb +12 -0
  14. data/lib/llmemory/long_term/file_based/memory.rb +46 -0
  15. data/lib/llmemory/long_term/graph_based/memory.rb +30 -3
  16. data/lib/llmemory/long_term/procedural/memory.rb +116 -0
  17. data/lib/llmemory/long_term/procedural/skill.rb +93 -0
  18. data/lib/llmemory/long_term/procedural/storage.rb +31 -0
  19. data/lib/llmemory/long_term/procedural/storages/base.rb +53 -0
  20. data/lib/llmemory/long_term/procedural/storages/file_storage.rb +136 -0
  21. data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +80 -0
  22. data/lib/llmemory/long_term/procedural.rb +12 -0
  23. data/lib/llmemory/long_term.rb +3 -0
  24. data/lib/llmemory/memory.rb +9 -1
  25. data/lib/llmemory/memory_module.rb +55 -0
  26. data/lib/llmemory/reflection/reflector.rb +116 -0
  27. data/lib/llmemory/reflection.rb +8 -0
  28. data/lib/llmemory/retrieval/engine.rb +115 -6
  29. data/lib/llmemory/retrieval/feedback_store.rb +50 -0
  30. data/lib/llmemory/short_term/checkpoint.rb +2 -14
  31. data/lib/llmemory/short_term/session_lifecycle.rb +3 -10
  32. data/lib/llmemory/short_term/stores.rb +27 -0
  33. data/lib/llmemory/version.rb +1 -1
  34. data/lib/llmemory/working_memory.rb +83 -0
  35. data/lib/llmemory.rb +5 -0
  36. metadata +24 -1
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+ require_relative "base"
7
+
8
+ module Llmemory
9
+ module LongTerm
10
+ module Procedural
11
+ module Storages
12
+ class FileStorage < Base
13
+ def initialize(base_path: nil)
14
+ @base_path = base_path || Llmemory.configuration.long_term_storage_path || "./llmemory_data"
15
+ @base_path = File.expand_path(@base_path)
16
+ end
17
+
18
+ def save_skill(user_id, skill)
19
+ id = skill[:id] || skill["id"] || "skill_#{next_seq(user_id)}"
20
+ data = stringify_for_json(skill).merge("id" => id, "user_id" => user_id)
21
+ data["created_at"] ||= Time.now.iso8601(6)
22
+ File.write(skill_path(user_id, id), JSON.generate(data))
23
+ id
24
+ end
25
+
26
+ def get_skill(user_id, id)
27
+ path = skill_path(user_id, id)
28
+ return nil unless File.file?(path)
29
+ load_skill(path)
30
+ end
31
+
32
+ def list_skills(user_id, limit: nil)
33
+ sorted = all_skills(user_id).sort_by { |s| s[:created_at] }.reverse
34
+ limit && limit.to_i.positive? ? sorted.first(limit.to_i) : sorted
35
+ end
36
+
37
+ def search_skills(user_id, query)
38
+ q = query.to_s.downcase
39
+ return list_skills(user_id) if q.strip.empty?
40
+ all_skills(user_id).select { |s| skill_text(s).downcase.include?(q) }
41
+ end
42
+
43
+ def find_skills_by_name(user_id, name)
44
+ all_skills(user_id).select { |s| s[:name].to_s == name.to_s }
45
+ end
46
+
47
+ def record_outcome(user_id, skill_id, success:)
48
+ skill = get_skill(user_id, skill_id)
49
+ return nil unless skill
50
+ key = success ? :success_count : :failure_count
51
+ skill[key] = (skill[key] || 0).to_i + 1
52
+ skill[:updated_at] = Time.now.iso8601(6)
53
+ File.write(skill_path(user_id, skill_id), JSON.generate(stringify_for_json(skill)))
54
+ skill
55
+ end
56
+
57
+ def count_skills(user_id)
58
+ dir = user_path(user_id, "skills")
59
+ return 0 unless Dir.exist?(dir)
60
+ Dir.children(dir).count { |f| f.end_with?(".json") }
61
+ end
62
+
63
+ def delete_skills(user_id, ids)
64
+ Array(ids).map(&:to_s).count do |id|
65
+ path = skill_path(user_id, id)
66
+ next false unless File.file?(path)
67
+ File.delete(path)
68
+ true
69
+ end
70
+ end
71
+
72
+ def list_users
73
+ return [] unless Dir.exist?(@base_path)
74
+ Dir.children(@base_path).select { |d| Dir.exist?(File.join(@base_path, d, "skills")) }
75
+ end
76
+
77
+ private
78
+
79
+ def all_skills(user_id)
80
+ dir = user_path(user_id, "skills")
81
+ return [] unless Dir.exist?(dir)
82
+ Dir.children(dir).select { |f| f.end_with?(".json") }.map { |f| load_skill(File.join(dir, f)) }.compact
83
+ end
84
+
85
+ def load_skill(path)
86
+ data = JSON.parse(File.read(path), symbolize_names: true)
87
+ data[:created_at] = parse_time(data[:created_at])
88
+ data[:updated_at] = parse_time(data[:updated_at]) if data[:updated_at]
89
+ data
90
+ rescue JSON::ParserError
91
+ nil
92
+ end
93
+
94
+ def skill_text(skill)
95
+ [skill[:name], skill[:description], skill[:body]].compact.join("\n")
96
+ end
97
+
98
+ def stringify_for_json(skill)
99
+ JSON.parse(JSON.generate(skill))
100
+ end
101
+
102
+ def user_path(user_id, *parts)
103
+ safe = user_id.to_s.gsub(%r{[^\w\-.]}, "_")
104
+ File.join(@base_path, safe, *parts)
105
+ end
106
+
107
+ def skill_path(user_id, id)
108
+ dir = user_path(user_id, "skills")
109
+ FileUtils.mkdir_p(dir)
110
+ File.join(dir, "#{id}.json")
111
+ end
112
+
113
+ def meta_path(user_id)
114
+ FileUtils.mkdir_p(user_path(user_id))
115
+ File.join(user_path(user_id), "meta.json")
116
+ end
117
+
118
+ def next_seq(user_id)
119
+ path = meta_path(user_id)
120
+ meta = File.file?(path) ? JSON.parse(File.read(path)) : {}
121
+ meta["skill_id_seq"] = (meta["skill_id_seq"] || 0) + 1
122
+ File.write(path, JSON.generate(meta))
123
+ meta["skill_id_seq"]
124
+ end
125
+
126
+ def parse_time(val)
127
+ return val if val.is_a?(Time)
128
+ Time.parse(val.to_s)
129
+ rescue ArgumentError
130
+ Time.now
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Llmemory
6
+ module LongTerm
7
+ module Procedural
8
+ module Storages
9
+ class MemoryStorage < Base
10
+ def initialize
11
+ @skills = Hash.new { |h, k| h[k] = [] }
12
+ @seq = 0
13
+ end
14
+
15
+ def save_skill(user_id, skill)
16
+ @seq += 1
17
+ id = skill[:id] || skill["id"] || "skill_#{@seq}"
18
+ record = symbolize(skill).merge(id: id, user_id: user_id)
19
+ record[:created_at] ||= Time.now
20
+ @skills[user_id] << record
21
+ id
22
+ end
23
+
24
+ def get_skill(user_id, id)
25
+ @skills[user_id].find { |s| s[:id] == id }
26
+ end
27
+
28
+ def list_skills(user_id, limit: nil)
29
+ sorted = @skills[user_id].sort_by { |s| s[:created_at] }.reverse
30
+ limit && limit.to_i.positive? ? sorted.first(limit.to_i) : sorted
31
+ end
32
+
33
+ def search_skills(user_id, query)
34
+ q = query.to_s.downcase
35
+ return list_skills(user_id) if q.strip.empty?
36
+ @skills[user_id].select { |s| skill_text(s).downcase.include?(q) }
37
+ end
38
+
39
+ def find_skills_by_name(user_id, name)
40
+ @skills[user_id].select { |s| s[:name].to_s == name.to_s }
41
+ end
42
+
43
+ def record_outcome(user_id, skill_id, success:)
44
+ skill = get_skill(user_id, skill_id)
45
+ return nil unless skill
46
+ key = success ? :success_count : :failure_count
47
+ skill[key] = (skill[key] || 0).to_i + 1
48
+ skill[:updated_at] = Time.now
49
+ skill
50
+ end
51
+
52
+ def count_skills(user_id)
53
+ @skills[user_id].size
54
+ end
55
+
56
+ def delete_skills(user_id, ids)
57
+ ids = Array(ids).map(&:to_s)
58
+ before = @skills[user_id].size
59
+ @skills[user_id].reject! { |s| ids.include?(s[:id].to_s) }
60
+ before - @skills[user_id].size
61
+ end
62
+
63
+ def list_users
64
+ @skills.keys
65
+ end
66
+
67
+ private
68
+
69
+ def symbolize(hash)
70
+ hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
71
+ end
72
+
73
+ def skill_text(skill)
74
+ [skill[:name], skill[:description], skill[:body]].compact.join("\n")
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "procedural/skill"
4
+ require_relative "procedural/storage"
5
+ require_relative "procedural/memory"
6
+
7
+ module Llmemory
8
+ module LongTerm
9
+ module Procedural
10
+ end
11
+ end
12
+ end
@@ -1,7 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "memory_module"
3
4
  require_relative "long_term/file_based"
4
5
  require_relative "long_term/graph_based"
6
+ require_relative "long_term/episodic"
7
+ require_relative "long_term/procedural"
5
8
 
6
9
  module Llmemory
7
10
  module LongTerm
@@ -10,16 +10,24 @@ 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, 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, 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
+ @working_memory = working_memory
17
18
  @llm = api_key.to_s.empty? ? nil : Llmemory::LLM.client(api_key: api_key)
18
19
  type = long_term_type || Llmemory.configuration.long_term_type || :file_based
19
20
  @long_term = long_term || build_long_term(type)
20
21
  @retrieval_engine = retrieval_engine || Retrieval::Engine.new(@long_term, llm: @llm)
21
22
  end
22
23
 
24
+ # 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
+ def working_memory
28
+ @working_memory ||= WorkingMemory.new(user_id: @user_id, session_id: @session_id)
29
+ end
30
+
23
31
  def add_message(role:, content:)
24
32
  msgs = messages
25
33
  msgs << { role: role.to_sym, content: content.to_s }
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ # Uniform contract for queryable long-term memories (file-based, graph-based,
5
+ # episodic). CoALA argues agents should be modular with standardized
6
+ # abstractions; this mixin gives any memory store the same agent-facing
7
+ # surface so frameworks can treat them polymorphically:
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
13
+ #
14
+ # `read` defaults to the de-facto `search_candidates` interface the retrieval
15
+ # Engine already relies on. `write`, `list` and `stats` are implemented by each
16
+ # including class over its native API.
17
+ #
18
+ # Deliberately excluded: session-state stores (Checkpoint, WorkingMemory) are a
19
+ # different abstraction (K/V session state, not a retrievable corpus), and
20
+ # deletion/forgetting semantics are deferred to a coherent unlearning API.
21
+ module MemoryModule
22
+ def read(query, user_id: nil, limit: 20)
23
+ unless respond_to?(:search_candidates)
24
+ raise NotImplementedError, "#{self.class} must implement #read or #search_candidates"
25
+ end
26
+ search_candidates(query, user_id: user_id, top_k: limit)
27
+ end
28
+
29
+ def write(*, **)
30
+ raise NotImplementedError, "#{self.class} must implement #write"
31
+ end
32
+
33
+ def list(user_id: nil, limit: nil)
34
+ raise NotImplementedError, "#{self.class} must implement #list"
35
+ end
36
+
37
+ def stats(user_id: nil)
38
+ raise NotImplementedError, "#{self.class} must implement #stats"
39
+ end
40
+
41
+ # Removes entries by id (the same ids returned by #read) and records the
42
+ # removal in a ForgetLog audit. Returns the number of entries removed.
43
+ # Implemented by stores with a clear deletion model; others may not support
44
+ # it (CoALA-style "unlearning" is understudied; deletion semantics differ).
45
+ def forget(ids:, reason: nil)
46
+ raise NotImplementedError, "#{self.class} does not support #forget"
47
+ end
48
+
49
+ # Shared audit trail for #forget. Lazily built; override or inject by setting
50
+ # @forget_log if a specific backend/store is required.
51
+ def forget_log
52
+ @forget_log ||= Llmemory::ForgetLog.new
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Llmemory
6
+ module Reflection
7
+ # Reflection distills an agent's recent episodes (episodic memory) into
8
+ # durable, higher-order insights and writes them to semantic memory. This is
9
+ # CoALA's "updating semantic memory with knowledge" (Reflexion / Generative
10
+ # Agents): unlike one-shot extraction from raw text, it reasons over lived
11
+ # experience to generalize lessons and patterns.
12
+ #
13
+ # Each insight is stored with provenance { method: "reflection",
14
+ # sources: [{ type: "episode", id: ... }] } so it stays traceable to the
15
+ # experiences that produced it.
16
+ #
17
+ # `semantic` must respond to:
18
+ # remember_fact(content:, category:, importance:, provenance:)
19
+ # (FileBased::Memory implements this; graph-based is a future target.)
20
+ class Reflector
21
+ DEFAULT_CATEGORY = "insights"
22
+ DEFAULT_IMPORTANCE = 0.6
23
+
24
+ def initialize(episodic:, semantic:, llm: nil)
25
+ @episodic = episodic
26
+ @semantic = semantic
27
+ @llm = llm || Llmemory::LLM.client
28
+ end
29
+
30
+ # Reflects over the most recent `window` episodes and writes the resulting
31
+ # insights to semantic memory. Returns the ids of the stored insights.
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
+ )
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def distill(episodes)
59
+ response = @llm.invoke(build_prompt(episodes))
60
+ parse_insights(response)
61
+ rescue Llmemory::LLMError
62
+ []
63
+ end
64
+
65
+ def build_prompt(episodes)
66
+ episodes_text = episodes.each_with_index.map do |ep, i|
67
+ "Episode #{i + 1} (outcome: #{ep.outcome || 'n/a'}):\n#{ep.searchable_text}"
68
+ end.join("\n\n")
69
+
70
+ <<~PROMPT
71
+ You are reflecting on an agent's recent experiences to distill durable,
72
+ higher-order insights: lessons learned, recurring patterns, and stable
73
+ preferences that will help in future situations. Generalize; do not
74
+ restate raw events.
75
+
76
+ Recent episodes:
77
+ #{episodes_text}
78
+
79
+ Return a JSON array of objects with "content" (the insight) and
80
+ "confidence" (0-1) keys. Return an empty array if nothing durable can
81
+ be concluded.
82
+ Example: [{"content": "Rolling back on deploy failure reliably restores service", "confidence": 0.8}]
83
+ PROMPT
84
+ end
85
+
86
+ def parse_insights(response)
87
+ json = extract_json_array(response)
88
+ return [] unless json
89
+
90
+ json.filter_map do |item|
91
+ next nil unless item.is_a?(Hash)
92
+ content = item["content"] || item[:content]
93
+ next nil if content.to_s.strip.empty?
94
+ { content: content.to_s, confidence: normalize_confidence(item["confidence"] || item[:confidence]) }
95
+ end
96
+ end
97
+
98
+ def normalize_confidence(value)
99
+ return nil if value.nil?
100
+ v = value.to_f
101
+ v.between?(0, 1) ? v : nil
102
+ end
103
+
104
+ def extract_json_array(response)
105
+ response = response.to_s.strip
106
+ start_idx = response.index("[")
107
+ end_idx = response.rindex("]")
108
+ return nil unless start_idx && end_idx
109
+
110
+ JSON.parse(response[start_idx..end_idx])
111
+ rescue JSON::ParserError
112
+ nil
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "reflection/reflector"
4
+
5
+ module Llmemory
6
+ module Reflection
7
+ end
8
+ end
@@ -4,34 +4,142 @@ require_relative "temporal_ranker"
4
4
  require_relative "context_assembler"
5
5
  require_relative "bm25_scorer"
6
6
  require_relative "mmr_reranker"
7
+ require_relative "feedback_store"
7
8
 
8
9
  module Llmemory
9
10
  module Retrieval
10
11
  class Engine
11
12
  RELEVANCE_THRESHOLD = 0.7
13
+ FEEDBACK_CAP = 5
12
14
 
13
- def initialize(memory, llm: nil)
15
+ def initialize(memory, llm: nil, feedback: nil)
14
16
  @memory = memory
15
17
  @llm = llm || Llmemory::LLM.client
16
18
  @ranker = TemporalRanker.new
17
19
  @assembler = ContextAssembler.new
18
20
  @bm25_scorer = Bm25Scorer.new
19
21
  @mmr_reranker = MmrReranker.new(lambda: Llmemory.configuration.mmr_lambda)
22
+ @feedback = feedback || FeedbackStore.new
20
23
  end
21
24
 
22
25
  def retrieve_for_inference(user_message, user_id: nil, max_tokens: nil)
23
26
  user_id ||= @memory.respond_to?(:user_id) ? @memory.user_id : nil
24
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)
30
+ end
31
+
32
+ # Multi-hop retrieval (CoALA: integrating retrieval and reasoning). After
33
+ # each hop, a reasoner inspects what has been retrieved and proposes a
34
+ # follow-up query for the missing piece, enabling multi-hop questions a
35
+ # single retrieval would miss. Candidates accumulate (deduped) across hops.
36
+ #
37
+ # `reasoner` is a callable (user_message, accumulated_candidates, hop) ->
38
+ # next query String, or "DONE"/blank to stop. Defaults to an LLM that
39
+ # proposes the next sub-query. Converges on `max_hops`, "DONE", a blank
40
+ # query, or a repeated query.
41
+ def iterative_retrieve(user_message, user_id: nil, max_tokens: nil, max_hops: 2, reasoner: nil)
42
+ user_id ||= @memory.respond_to?(:user_id) ? @memory.user_id : nil
43
+ reasoner ||= method(:default_followup_query)
44
+
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
55
+
56
+ query = reasoner.call(user_message, accumulated, hop).to_s.strip
57
+ end
58
+
59
+ final = accumulated.sort_by { |c| -(c[:temporal_score] || c[:score] || 0) }
60
+ @assembler.assemble(final, max_tokens: max_tokens)
61
+ end
62
+
63
+ # Records that previously-retrieved items were useful or harmful for the
64
+ # agent's task. Repeatedly useful items rank higher in future retrievals;
65
+ # noisy ones are dampened. Item ids come from the candidates returned by
66
+ # the memory's #read / #search_candidates.
67
+ def report_feedback(useful_ids: [], harmful_ids: [], user_id: nil)
68
+ user_id ||= @memory.respond_to?(:user_id) ? @memory.user_id : nil
69
+ Array(useful_ids).each { |id| @feedback.record(user_id, id, 1) }
70
+ Array(harmful_ids).each { |id| @feedback.record(user_id, id, -1) }
71
+ true
72
+ end
73
+
74
+ private
75
+
76
+ # One retrieval hop: fetch -> hybrid -> relevance filter -> temporal rank
77
+ # -> feedback adjust -> (optional) MMR. Returns ranked candidates.
78
+ def ranked_candidates(search_query, user_id, relevance_text)
25
79
  candidates = fetch_candidates(search_query, user_id)
26
80
  candidates = apply_hybrid_scoring(candidates, search_query) if Llmemory.configuration.hybrid_search_enabled
27
-
28
- relevant = filter_by_relevance(candidates, user_message)
81
+ relevant = filter_by_relevance(candidates, relevance_text)
29
82
  ranked = @ranker.rank(relevant)
30
- ranked = @mmr_reranker.rerank(ranked) if Llmemory.configuration.mmr_enabled
31
- @assembler.assemble(ranked, max_tokens: max_tokens)
83
+ ranked = apply_feedback(ranked, user_id)
84
+ Llmemory.configuration.mmr_enabled ? @mmr_reranker.rerank(ranked) : ranked
32
85
  end
33
86
 
34
- private
87
+ def live_query?(query)
88
+ !query.nil? && !query.to_s.strip.empty? && query.to_s.strip.upcase != "DONE"
89
+ end
90
+
91
+ def merge_candidates(accumulated, additions)
92
+ by_key = {}
93
+ (accumulated + additions).each do |c|
94
+ key = c[:id] || c[:text]
95
+ current = by_key[key]
96
+ if current.nil? || score_of(c) > score_of(current)
97
+ by_key[key] = c
98
+ end
99
+ end
100
+ by_key.values
101
+ end
102
+
103
+ def score_of(candidate)
104
+ (candidate[:temporal_score] || candidate[:score] || 0).to_f
105
+ end
106
+
107
+ def default_followup_query(user_message, accumulated, _hop)
108
+ context = accumulated.first(10).map { |c| c[:text] }.compact.join("\n")
109
+ prompt = <<~PROMPT
110
+ Question: #{user_message}
111
+ Information retrieved so far:
112
+ #{context}
113
+
114
+ If more information is needed to fully answer the question, reply with a
115
+ single short search query for the missing piece. If what was retrieved is
116
+ sufficient, reply with exactly "DONE".
117
+ PROMPT
118
+ @llm.invoke(prompt.strip).to_s.strip
119
+ rescue Llmemory::LLMError
120
+ "DONE"
121
+ end
122
+
123
+ def apply_feedback(ranked, user_id)
124
+ weight = Llmemory.configuration.retrieval_feedback_weight.to_f
125
+ return ranked if user_id.nil? || weight <= 0
126
+
127
+ adjusted = ranked.map do |c|
128
+ id = c[:id] || c["id"]
129
+ net = id.nil? ? 0 : @feedback.net(user_id, id)
130
+ next c if net.zero?
131
+
132
+ base = (c[:temporal_score] || c[:score] || 0).to_f
133
+ c.merge(temporal_score: base * feedback_factor(net, weight))
134
+ end
135
+ adjusted.sort_by { |c| -(c[:temporal_score] || 0) }
136
+ end
137
+
138
+ # Maps net feedback to a bounded multiplier in [1 - weight, 1 + weight].
139
+ def feedback_factor(net, weight)
140
+ capped = [[net, -FEEDBACK_CAP].max, FEEDBACK_CAP].min
141
+ 1.0 + (weight * (capped.to_f / FEEDBACK_CAP))
142
+ end
35
143
 
36
144
  def generate_query(user_message)
37
145
  return user_message.to_s if user_message.to_s.length <= 100
@@ -51,6 +159,7 @@ module Llmemory
51
159
  raw = @memory.search_candidates(search_query, user_id: user_id, top_k: 20)
52
160
  raw.map do |c|
53
161
  {
162
+ id: c[:id] || c["id"],
54
163
  text: c[:text] || c["text"],
55
164
  timestamp: parse_timestamp(c[:timestamp] || c["timestamp"] || c[:created_at] || c["created_at"]),
56
165
  score: (c[:score] || c["score"] || 1.0).to_f,
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../short_term/stores"
4
+
5
+ module Llmemory
6
+ module Retrieval
7
+ # Persists retrieval feedback: a net utility signal per (user, memory item),
8
+ # accumulated from agents marking retrieved items useful (+1) or harmful (-1).
9
+ #
10
+ # CoALA flags adaptive retrieval — "learning better retrieval procedures" — as
11
+ # understudied. This is the minimal substrate for it: a feedback ledger the
12
+ # Engine consults to boost repeatedly-useful items and dampen noise.
13
+ #
14
+ # Backed by the same pluggable short-term stores as Checkpoint/WorkingMemory,
15
+ # under a per-user pseudo-session key.
16
+ class FeedbackStore
17
+ SESSION_KEY = "__retrieval_feedback__"
18
+
19
+ def initialize(store: nil)
20
+ @store = store || ShortTerm::Stores.build
21
+ end
22
+
23
+ def record(user_id, item_id, delta)
24
+ return if user_id.nil? || item_id.nil?
25
+ state = load(user_id)
26
+ key = item_id.to_s
27
+ state[key] = (state[key] || 0) + delta.to_i
28
+ @store.save(user_id, SESSION_KEY, state)
29
+ state[key]
30
+ end
31
+
32
+ def net(user_id, item_id)
33
+ return 0 if user_id.nil? || item_id.nil?
34
+ load(user_id)[item_id.to_s] || 0
35
+ end
36
+
37
+ def all(user_id)
38
+ load(user_id)
39
+ end
40
+
41
+ private
42
+
43
+ def load(user_id)
44
+ state = @store.load(user_id, SESSION_KEY)
45
+ return {} unless state.is_a?(Hash)
46
+ state.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v.to_i }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -1,9 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "stores/base"
4
- require_relative "stores/memory_store"
5
- require_relative "stores/redis_store"
6
- require_relative "stores/postgres_store"
3
+ require_relative "stores"
7
4
 
8
5
  module Llmemory
9
6
  module ShortTerm
@@ -31,16 +28,7 @@ module Llmemory
31
28
  private
32
29
 
33
30
  def build_store
34
- case Llmemory.configuration.short_term_store.to_sym
35
- when :memory then Stores::MemoryStore.new
36
- when :redis then Stores::RedisStore.new
37
- when :postgres then Stores::PostgresStore.new
38
- when :active_record, :activerecord
39
- require_relative "stores/active_record_store"
40
- Stores::ActiveRecordStore.new
41
- else
42
- Stores::MemoryStore.new
43
- end
31
+ Stores.build
44
32
  end
45
33
  end
46
34
  end