llmemory 0.1.1

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 (63) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +193 -0
  4. data/lib/generators/llmemory/install/install_generator.rb +24 -0
  5. data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +73 -0
  6. data/lib/llmemory/configuration.rb +51 -0
  7. data/lib/llmemory/extractors/entity_relation_extractor.rb +74 -0
  8. data/lib/llmemory/extractors/fact_extractor.rb +74 -0
  9. data/lib/llmemory/extractors.rb +9 -0
  10. data/lib/llmemory/llm/anthropic.rb +48 -0
  11. data/lib/llmemory/llm/base.rb +17 -0
  12. data/lib/llmemory/llm/openai.rb +46 -0
  13. data/lib/llmemory/llm.rb +18 -0
  14. data/lib/llmemory/long_term/file_based/category.rb +22 -0
  15. data/lib/llmemory/long_term/file_based/item.rb +31 -0
  16. data/lib/llmemory/long_term/file_based/memory.rb +83 -0
  17. data/lib/llmemory/long_term/file_based/resource.rb +22 -0
  18. data/lib/llmemory/long_term/file_based/retrieval.rb +90 -0
  19. data/lib/llmemory/long_term/file_based/storage.rb +35 -0
  20. data/lib/llmemory/long_term/file_based/storages/active_record_models.rb +26 -0
  21. data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +144 -0
  22. data/lib/llmemory/long_term/file_based/storages/base.rb +71 -0
  23. data/lib/llmemory/long_term/file_based/storages/database_storage.rb +231 -0
  24. data/lib/llmemory/long_term/file_based/storages/file_storage.rb +180 -0
  25. data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +100 -0
  26. data/lib/llmemory/long_term/file_based.rb +15 -0
  27. data/lib/llmemory/long_term/graph_based/conflict_resolver.rb +33 -0
  28. data/lib/llmemory/long_term/graph_based/edge.rb +49 -0
  29. data/lib/llmemory/long_term/graph_based/knowledge_graph.rb +114 -0
  30. data/lib/llmemory/long_term/graph_based/memory.rb +143 -0
  31. data/lib/llmemory/long_term/graph_based/node.rb +42 -0
  32. data/lib/llmemory/long_term/graph_based/storage.rb +24 -0
  33. data/lib/llmemory/long_term/graph_based/storages/active_record_models.rb +23 -0
  34. data/lib/llmemory/long_term/graph_based/storages/active_record_storage.rb +132 -0
  35. data/lib/llmemory/long_term/graph_based/storages/base.rb +39 -0
  36. data/lib/llmemory/long_term/graph_based/storages/memory_storage.rb +106 -0
  37. data/lib/llmemory/long_term/graph_based.rb +15 -0
  38. data/lib/llmemory/long_term.rb +9 -0
  39. data/lib/llmemory/maintenance/consolidator.rb +55 -0
  40. data/lib/llmemory/maintenance/reindexer.rb +27 -0
  41. data/lib/llmemory/maintenance/runner.rb +34 -0
  42. data/lib/llmemory/maintenance/summarizer.rb +57 -0
  43. data/lib/llmemory/maintenance.rb +8 -0
  44. data/lib/llmemory/memory.rb +96 -0
  45. data/lib/llmemory/retrieval/context_assembler.rb +53 -0
  46. data/lib/llmemory/retrieval/engine.rb +74 -0
  47. data/lib/llmemory/retrieval/temporal_ranker.rb +23 -0
  48. data/lib/llmemory/retrieval.rb +10 -0
  49. data/lib/llmemory/short_term/checkpoint.rb +47 -0
  50. data/lib/llmemory/short_term/stores/active_record_checkpoint.rb +14 -0
  51. data/lib/llmemory/short_term/stores/active_record_store.rb +58 -0
  52. data/lib/llmemory/short_term/stores/base.rb +21 -0
  53. data/lib/llmemory/short_term/stores/memory_store.rb +37 -0
  54. data/lib/llmemory/short_term/stores/postgres_store.rb +80 -0
  55. data/lib/llmemory/short_term/stores/redis_store.rb +54 -0
  56. data/lib/llmemory/short_term.rb +8 -0
  57. data/lib/llmemory/vector_store/base.rb +19 -0
  58. data/lib/llmemory/vector_store/memory_store.rb +53 -0
  59. data/lib/llmemory/vector_store/openai_embeddings.rb +49 -0
  60. data/lib/llmemory/vector_store.rb +10 -0
  61. data/lib/llmemory/version.rb +5 -0
  62. data/lib/llmemory.rb +19 -0
  63. metadata +163 -0
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module LongTerm
5
+ module FileBased
6
+ class Category
7
+ attr_reader :user_id, :name, :content, :updated_at
8
+
9
+ def initialize(user_id:, name:, content: "", updated_at: nil)
10
+ @user_id = user_id
11
+ @name = name
12
+ @content = content
13
+ @updated_at = updated_at || Time.now
14
+ end
15
+
16
+ def to_h
17
+ { user_id: user_id, name: name, content: content, updated_at: updated_at.iso8601 }
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module LongTerm
5
+ module FileBased
6
+ class Item
7
+ attr_reader :id, :user_id, :category, :content, :source_resource_id, :created_at
8
+
9
+ def initialize(id:, user_id:, category:, content:, source_resource_id: nil, created_at: nil)
10
+ @id = id
11
+ @user_id = user_id
12
+ @category = category
13
+ @content = content
14
+ @source_resource_id = source_resource_id
15
+ @created_at = created_at || Time.now
16
+ end
17
+
18
+ def to_h
19
+ {
20
+ id: id,
21
+ user_id: user_id,
22
+ category: category,
23
+ content: content,
24
+ source_resource_id: source_resource_id,
25
+ created_at: created_at.iso8601
26
+ }
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+ require_relative "item"
5
+ require_relative "category"
6
+ require_relative "storage"
7
+
8
+ module Llmemory
9
+ module LongTerm
10
+ module FileBased
11
+ class Memory
12
+ def initialize(user_id:, storage: nil, llm: nil, extractor: nil)
13
+ @user_id = user_id
14
+ @storage = storage || Storages.build
15
+ @llm = llm || Llmemory::LLM.client
16
+ @extractor = extractor || Llmemory::Extractors::FactExtractor.new(llm: @llm)
17
+ end
18
+
19
+ def memorize(conversation_text)
20
+ resource_id = save_resource(conversation_text)
21
+ items = @extractor.extract_items(conversation_text)
22
+ updates_by_category = {}
23
+
24
+ items.each do |item|
25
+ content = item.is_a?(Hash) ? (item["content"] || item[:content]) : item.to_s
26
+ cat = @extractor.classify_item(content)
27
+ updates_by_category[cat] ||= []
28
+ updates_by_category[cat] << content.to_s
29
+ save_item(category: cat, item: item, source_resource_id: resource_id)
30
+ end
31
+
32
+ updates_by_category.each do |category, new_memories|
33
+ existing_summary = @storage.load_category(@user_id, category)
34
+ updated_summary = @extractor.evolve_summary(existing: existing_summary, new_memories: new_memories)
35
+ @storage.save_category(@user_id, category, updated_summary)
36
+ end
37
+
38
+ true
39
+ end
40
+
41
+ def retrieve(query)
42
+ retrieval = Retrieval.new(user_id: @user_id, storage: @storage, llm: @llm)
43
+ retrieval.retrieve(query)
44
+ end
45
+
46
+ def search_candidates(query, user_id: nil, top_k: 20)
47
+ uid = user_id || @user_id
48
+ items = @storage.search_items(uid, query)
49
+ resources = @storage.search_resources(uid, query)
50
+ out = []
51
+ items.first(top_k).each do |i|
52
+ out << {
53
+ text: i[:content] || i["content"],
54
+ timestamp: i[:created_at] || i["created_at"],
55
+ score: 1.0
56
+ }
57
+ end
58
+ resources.first([top_k - out.size, 0].max).each do |r|
59
+ out << {
60
+ text: r[:text] || r["text"],
61
+ timestamp: r[:created_at] || r["created_at"],
62
+ score: 0.9
63
+ }
64
+ end
65
+ out
66
+ end
67
+
68
+ attr_reader :storage, :user_id
69
+
70
+ private
71
+
72
+ def save_resource(text)
73
+ @storage.save_resource(@user_id, text)
74
+ end
75
+
76
+ def save_item(category:, item:, source_resource_id:)
77
+ content = item.is_a?(Hash) ? item["content"] || item[:content] : item.to_s
78
+ @storage.save_item(@user_id, category: category, content: content, source_resource_id: source_resource_id)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module LongTerm
5
+ module FileBased
6
+ class Resource
7
+ attr_reader :id, :user_id, :text, :created_at
8
+
9
+ def initialize(id:, user_id:, text:, created_at: nil)
10
+ @id = id
11
+ @user_id = user_id
12
+ @text = text
13
+ @created_at = created_at || Time.now
14
+ end
15
+
16
+ def to_h
17
+ { id: id, user_id: user_id, text: text, created_at: created_at.iso8601 }
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Llmemory
6
+ module LongTerm
7
+ module FileBased
8
+ class Retrieval
9
+ def initialize(user_id:, storage:, llm: nil)
10
+ @user_id = user_id
11
+ @storage = storage
12
+ @llm = llm || Llmemory::LLM.client
13
+ end
14
+
15
+ def retrieve(query)
16
+ all_categories = @storage.list_categories(@user_id)
17
+ return {} if all_categories.empty?
18
+
19
+ relevant_categories = select_relevant_categories(query, all_categories)
20
+ summaries = {}
21
+ relevant_categories.each { |cat| summaries[cat] = @storage.load_category(@user_id, cat) }
22
+
23
+ return summaries if is_sufficient?(query, summaries)
24
+
25
+ search_query = generate_search_query(query, summaries)
26
+ items = @storage.search_items(@user_id, search_query)
27
+ return format_items(items) if items.any?
28
+
29
+ resources = @storage.search_resources(@user_id, search_query)
30
+ format_resources(resources)
31
+ end
32
+
33
+ private
34
+
35
+ def select_relevant_categories(query, categories)
36
+ return categories if categories.size <= 3
37
+ prompt = <<~PROMPT
38
+ Query: #{query}
39
+ Available Categories: #{categories.join(', ')}
40
+
41
+ Return a JSON array of the categories most relevant to this query. Example: ["work_life", "preferences"]
42
+ PROMPT
43
+ response = @llm.invoke(prompt.strip)
44
+ parsed = parse_categories_response(response)
45
+ parsed.any? ? parsed : categories.first(3)
46
+ end
47
+
48
+ def parse_categories_response(response)
49
+ json = response.to_s.strip
50
+ start_idx = json.index("[")
51
+ return [] unless start_idx
52
+ end_idx = json.rindex("]")
53
+ return [] unless end_idx
54
+ arr = JSON.parse(json[start_idx..end_idx])
55
+ arr.is_a?(Array) ? arr.map(&:to_s) : []
56
+ rescue JSON::ParserError
57
+ []
58
+ end
59
+
60
+ def is_sufficient?(query, summaries)
61
+ return false if summaries.values.all?(&:empty?)
62
+ prompt = <<~PROMPT
63
+ Query: #{query}
64
+ Summaries: #{summaries.to_json}
65
+ Can you answer the query comprehensively with just these summaries? Reply with exactly YES or NO.
66
+ PROMPT
67
+ response = @llm.invoke(prompt.strip).upcase
68
+ response.include?("YES")
69
+ end
70
+
71
+ def generate_search_query(query, summaries)
72
+ prompt = <<~PROMPT
73
+ Query: #{query}
74
+ Existing summary context: #{summaries.values.join("\n")[0..500]}
75
+ Generate a short search phrase (3-6 words) to find specific facts. Return only the phrase.
76
+ PROMPT
77
+ @llm.invoke(prompt.strip).to_s.strip
78
+ end
79
+
80
+ def format_items(items)
81
+ items.map { |i| i[:content] || i["content"] }.compact
82
+ end
83
+
84
+ def format_resources(resources)
85
+ resources.map { |r| r[:text] || r["text"] }.compact
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "storages/base"
4
+ require_relative "storages/memory_storage"
5
+ require_relative "storages/file_storage"
6
+ require_relative "storages/database_storage"
7
+
8
+ module Llmemory
9
+ module LongTerm
10
+ module FileBased
11
+ # Backward compatibility: Storage points to in-memory implementation.
12
+ # Use Storages::MemoryStorage, Storages::FileStorage, or Storages::DatabaseStorage explicitly,
13
+ # or build from config via Storages.build.
14
+ Storage = Storages::MemoryStorage
15
+
16
+ module Storages
17
+ def self.build(store: nil, base_path: nil, database_url: nil)
18
+ case (store || Llmemory.configuration.long_term_store).to_s.to_sym
19
+ when :memory
20
+ MemoryStorage.new
21
+ when :file
22
+ FileStorage.new(base_path: base_path || Llmemory.configuration.long_term_storage_path)
23
+ when :postgres, :database
24
+ DatabaseStorage.new(database_url: database_url || Llmemory.configuration.database_url)
25
+ when :active_record, :activerecord
26
+ require_relative "storages/active_record_storage"
27
+ ActiveRecordStorage.new
28
+ else
29
+ MemoryStorage.new
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Models for ActiveRecordStorage. Loaded only when using store: :active_record.
4
+ # In Rails, run: rails g llmemory:install (or create the migration manually).
5
+
6
+ module Llmemory
7
+ module LongTerm
8
+ module FileBased
9
+ module Storages
10
+ class LlmemoryResource < ::ActiveRecord::Base
11
+ self.table_name = "llmemory_resources"
12
+ self.primary_key = "id"
13
+ end
14
+
15
+ class LlmemoryItem < ::ActiveRecord::Base
16
+ self.table_name = "llmemory_items"
17
+ self.primary_key = "id"
18
+ end
19
+
20
+ class LlmemoryCategory < ::ActiveRecord::Base
21
+ self.table_name = "llmemory_categories"
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "base"
5
+
6
+ module Llmemory
7
+ module LongTerm
8
+ module FileBased
9
+ module Storages
10
+ class ActiveRecordStorage < Base
11
+ def initialize
12
+ self.class.load_models!
13
+ end
14
+
15
+ def self.load_models!
16
+ return if @models_loaded
17
+ require "active_record"
18
+ require_relative "active_record_models"
19
+ @models_loaded = true
20
+ end
21
+
22
+ def save_resource(user_id, text)
23
+ id = "res_#{SecureRandom.hex(8)}"
24
+ LlmemoryResource.create!(
25
+ id: id,
26
+ user_id: user_id,
27
+ text: text,
28
+ created_at: Time.current
29
+ )
30
+ id
31
+ end
32
+
33
+ def save_item(user_id, category:, content:, source_resource_id:)
34
+ id = "item_#{SecureRandom.hex(8)}"
35
+ LlmemoryItem.create!(
36
+ id: id,
37
+ user_id: user_id,
38
+ category: category,
39
+ content: content,
40
+ source_resource_id: source_resource_id,
41
+ created_at: Time.current
42
+ )
43
+ id
44
+ end
45
+
46
+ def load_category(user_id, category_name)
47
+ rec = LlmemoryCategory.find_by(user_id: user_id, category_name: category_name)
48
+ rec ? rec.content.to_s : ""
49
+ end
50
+
51
+ def save_category(user_id, category_name, content)
52
+ rec = LlmemoryCategory.find_or_initialize_by(user_id: user_id, category_name: category_name)
53
+ rec.content = content
54
+ rec.updated_at = Time.current
55
+ rec.save!
56
+ true
57
+ end
58
+
59
+ def list_categories(user_id)
60
+ LlmemoryCategory.where(user_id: user_id).pluck(:category_name)
61
+ end
62
+
63
+ def search_items(user_id, query)
64
+ q = "%#{sanitize_like(query)}%"
65
+ LlmemoryItem.where(user_id: user_id).where("LOWER(content) LIKE LOWER(?)", q).map { |r| row_to_item(r) }
66
+ end
67
+
68
+ def search_resources(user_id, query)
69
+ q = "%#{sanitize_like(query)}%"
70
+ LlmemoryResource.where(user_id: user_id).where("LOWER(text) LIKE LOWER(?)", q).map { |r| row_to_resource(r) }
71
+ end
72
+
73
+ def get_resources_since(user_id, hours:)
74
+ cutoff = hours.hours.ago
75
+ LlmemoryResource.where(user_id: user_id).where("created_at >= ?", cutoff).order(:created_at).map { |r| row_to_resource(r) }
76
+ end
77
+
78
+ def get_items_older_than(user_id, days:)
79
+ cutoff = days.days.ago
80
+ LlmemoryItem.where(user_id: user_id).where("created_at < ?", cutoff).order(:created_at).map { |r| row_to_item(r) }
81
+ end
82
+
83
+ def get_all_items(user_id)
84
+ LlmemoryItem.where(user_id: user_id).order(:created_at).map { |r| row_to_item(r) }
85
+ end
86
+
87
+ def get_all_resources(user_id)
88
+ LlmemoryResource.where(user_id: user_id).order(:created_at).map { |r| row_to_resource(r) }
89
+ end
90
+
91
+ def get_items_since(user_id, hours:)
92
+ cutoff = hours.hours.ago
93
+ LlmemoryItem.where(user_id: user_id).where("created_at >= ?", cutoff).order(:created_at).map { |r| row_to_item(r) }
94
+ end
95
+
96
+ def replace_items(user_id, ids_to_remove, merged_item)
97
+ LlmemoryItem.where(user_id: user_id, id: ids_to_remove).destroy_all
98
+ created_at = merged_item[:created_at] || Time.current
99
+ LlmemoryItem.create!(
100
+ id: "item_#{SecureRandom.hex(8)}",
101
+ user_id: user_id,
102
+ category: merged_item[:category],
103
+ content: merged_item[:content],
104
+ source_resource_id: merged_item[:source_resource_id],
105
+ created_at: created_at
106
+ )
107
+ end
108
+
109
+ def archive_items(user_id, item_ids)
110
+ LlmemoryItem.where(user_id: user_id, id: item_ids).destroy_all
111
+ end
112
+
113
+ def archive_resources(user_id, resource_ids)
114
+ LlmemoryResource.where(user_id: user_id, id: resource_ids).destroy_all
115
+ end
116
+
117
+ private
118
+
119
+ def sanitize_like(str)
120
+ (str || "").to_s.gsub(/[%_\\]/) { |c| "\\#{c}" }
121
+ end
122
+
123
+ def row_to_item(r)
124
+ {
125
+ id: r.id,
126
+ category: r.category,
127
+ content: r.content,
128
+ source_resource_id: r.source_resource_id,
129
+ created_at: r.created_at
130
+ }
131
+ end
132
+
133
+ def row_to_resource(r)
134
+ {
135
+ id: r.id,
136
+ text: r.text,
137
+ created_at: r.created_at
138
+ }
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module LongTerm
5
+ module FileBased
6
+ module Storages
7
+ class Base
8
+ def save_resource(user_id, text)
9
+ raise NotImplementedError, "#{self.class}#save_resource must be implemented"
10
+ end
11
+
12
+ def save_item(user_id, category:, content:, source_resource_id:)
13
+ raise NotImplementedError, "#{self.class}#save_item must be implemented"
14
+ end
15
+
16
+ def load_category(user_id, category_name)
17
+ raise NotImplementedError, "#{self.class}#load_category must be implemented"
18
+ end
19
+
20
+ def save_category(user_id, category_name, content)
21
+ raise NotImplementedError, "#{self.class}#save_category must be implemented"
22
+ end
23
+
24
+ def list_categories(user_id)
25
+ raise NotImplementedError, "#{self.class}#list_categories must be implemented"
26
+ end
27
+
28
+ def search_items(user_id, query)
29
+ raise NotImplementedError, "#{self.class}#search_items must be implemented"
30
+ end
31
+
32
+ def search_resources(user_id, query)
33
+ raise NotImplementedError, "#{self.class}#search_resources must be implemented"
34
+ end
35
+
36
+ def get_resources_since(user_id, hours:)
37
+ raise NotImplementedError, "#{self.class}#get_resources_since must be implemented"
38
+ end
39
+
40
+ def get_items_older_than(user_id, days:)
41
+ raise NotImplementedError, "#{self.class}#get_items_older_than must be implemented"
42
+ end
43
+
44
+ def get_all_items(user_id)
45
+ raise NotImplementedError, "#{self.class}#get_all_items must be implemented"
46
+ end
47
+
48
+ def get_all_resources(user_id)
49
+ raise NotImplementedError, "#{self.class}#get_all_resources must be implemented"
50
+ end
51
+
52
+ def get_items_since(user_id, hours:)
53
+ raise NotImplementedError, "#{self.class}#get_items_since must be implemented"
54
+ end
55
+
56
+ def replace_items(user_id, ids_to_remove, merged_item)
57
+ raise NotImplementedError, "#{self.class}#replace_items must be implemented"
58
+ end
59
+
60
+ def archive_items(user_id, item_ids)
61
+ raise NotImplementedError, "#{self.class}#archive_items must be implemented"
62
+ end
63
+
64
+ def archive_resources(user_id, resource_ids)
65
+ raise NotImplementedError, "#{self.class}#archive_resources must be implemented"
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end