llmemory 0.1.15 → 0.1.16

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 723fae20d0310ccaeaf9ba600148061d17b2a0b29f933d455d1cf656dee85636
4
- data.tar.gz: a135ea1661af46e96843bf52744e8004d0ebe7e8d94b0c46a097c36df53d5bc4
3
+ metadata.gz: 135c27c05f80b660972a5aa8df5ba315cbdd8235ac68c662ddcfe9249ca79109
4
+ data.tar.gz: 2e43bacc332ddb44613c79ebd7e4e832091fc68c451a75073860502962d496d9
5
5
  SHA512:
6
- metadata.gz: 256caaee94233d5e57b8d9e6007fe1ced57d35e21d40260ce34b2803ba0ef3593b66668aa06334e647edd103aa431113e38b639776163d71153c4b9bac68c1a1
7
- data.tar.gz: 33cd1726e9f7bb3328610bafabca5ebfe51f080e7d34c523fc0b363eb290b353c9109937f2782ed7e60906965236e79229838e32c698d2f0e2f73aa2d421970b
6
+ metadata.gz: 9b0d7d67c2647ec0392f993ed31aedc5d51fdc3dadf40bd1a511b90a55b24ce415a9b9a420cc6e676d5a3ba8eb780048e047eff9d0800618dc10e99c4c779a1f
7
+ data.tar.gz: 10184e66c86c94976d2c164f8717633fbb438e0e95cfb0dbffed4ce52f8bbf8c4266c0b3e48879c792250da15a385464983a886408c69598044c0f60c13f27e7
@@ -17,6 +17,7 @@ class CreateLlmemoryTables < ActiveRecord::Migration[7.0]
17
17
  t.text :content, null: false
18
18
  t.string :source_resource_id
19
19
  t.float :importance, default: 0.7
20
+ t.jsonb :provenance
20
21
  t.timestamps
21
22
  end
22
23
  add_index :llmemory_items, :user_id
@@ -14,6 +14,7 @@ module Llmemory
14
14
  :database_url,
15
15
  :vector_store,
16
16
  :time_decay_half_life_days,
17
+ :importance_weight,
17
18
  :max_retrieval_tokens,
18
19
  :prune_after_days,
19
20
  :compact_max_bytes,
@@ -56,6 +57,7 @@ module Llmemory
56
57
  @database_url = ENV["DATABASE_URL"]
57
58
  @vector_store = nil
58
59
  @time_decay_half_life_days = 30
60
+ @importance_weight = 1.0
59
61
  @max_retrieval_tokens = 2000
60
62
  @prune_after_days = 90
61
63
  @compact_max_bytes = 8192
@@ -4,14 +4,15 @@ module Llmemory
4
4
  module LongTerm
5
5
  module FileBased
6
6
  class Item
7
- attr_reader :id, :user_id, :category, :content, :source_resource_id, :created_at
7
+ attr_reader :id, :user_id, :category, :content, :source_resource_id, :provenance, :created_at
8
8
 
9
- def initialize(id:, user_id:, category:, content:, source_resource_id: nil, created_at: nil)
9
+ def initialize(id:, user_id:, category:, content:, source_resource_id: nil, provenance: nil, created_at: nil)
10
10
  @id = id
11
11
  @user_id = user_id
12
12
  @category = category
13
13
  @content = content
14
14
  @source_resource_id = source_resource_id
15
+ @provenance = provenance
15
16
  @created_at = created_at || Time.now
16
17
  end
17
18
 
@@ -22,6 +23,7 @@ module Llmemory
22
23
  category: category,
23
24
  content: content,
24
25
  source_resource_id: source_resource_id,
26
+ provenance: provenance,
25
27
  created_at: created_at.iso8601
26
28
  }
27
29
  end
@@ -65,7 +65,8 @@ module Llmemory
65
65
  out << {
66
66
  text: i[:content] || i["content"],
67
67
  timestamp: i[:created_at] || i["created_at"],
68
- score: (i[:importance] || i["importance"] || 1.0).to_f,
68
+ score: 1.0,
69
+ importance: (i[:importance] || i["importance"] || 1.0).to_f,
69
70
  evergreen: i[:evergreen] || i["evergreen"]
70
71
  }
71
72
  end
@@ -94,7 +95,10 @@ module Llmemory
94
95
 
95
96
  def save_item(category:, item:, source_resource_id:, importance: 0.7)
96
97
  content = item.is_a?(Hash) ? item["content"] || item[:content] : item.to_s
97
- @storage.save_item(@user_id, category: category, content: content, source_resource_id: source_resource_id, importance: importance)
98
+ provenance = Llmemory::Provenance.from_resource(
99
+ source_resource_id, method: "fact_extraction", confidence: importance
100
+ )
101
+ @storage.save_item(@user_id, category: category, content: content, source_resource_id: source_resource_id, importance: importance, provenance: provenance)
98
102
  end
99
103
 
100
104
  def append_to_daily_log(conversation_text)
@@ -30,7 +30,7 @@ module Llmemory
30
30
  id
31
31
  end
32
32
 
33
- def save_item(user_id, category:, content:, source_resource_id:, importance: 0.7)
33
+ def save_item(user_id, category:, content:, source_resource_id:, importance: 0.7, provenance: nil)
34
34
  id = "item_#{SecureRandom.hex(8)}"
35
35
  attrs = {
36
36
  id: id,
@@ -41,6 +41,7 @@ module Llmemory
41
41
  created_at: Time.current
42
42
  }
43
43
  attrs[:importance] = importance if LlmemoryItem.column_names.include?("importance")
44
+ attrs[:provenance] = provenance if provenance && LlmemoryItem.column_names.include?("provenance")
44
45
  LlmemoryItem.create!(attrs)
45
46
  id
46
47
  end
@@ -189,6 +190,7 @@ module Llmemory
189
190
  created_at: r.created_at
190
191
  }
191
192
  h[:importance] = r.respond_to?(:importance) ? (r.importance || 0.7).to_f : 0.7
193
+ h[:provenance] = r.provenance if r.respond_to?(:provenance)
192
194
  h
193
195
  end
194
196
 
@@ -9,7 +9,7 @@ module Llmemory
9
9
  raise NotImplementedError, "#{self.class}#save_resource must be implemented"
10
10
  end
11
11
 
12
- def save_item(user_id, category:, content:, source_resource_id:, importance: 0.7)
12
+ def save_item(user_id, category:, content:, source_resource_id:, importance: 0.7, provenance: nil)
13
13
  raise NotImplementedError, "#{self.class}#save_item must be implemented"
14
14
  end
15
15
 
@@ -24,12 +24,12 @@ module Llmemory
24
24
  id
25
25
  end
26
26
 
27
- def save_item(user_id, category:, content:, source_resource_id:, importance: 0.7)
27
+ def save_item(user_id, category:, content:, source_resource_id:, importance: 0.7, provenance: nil)
28
28
  ensure_tables!
29
29
  id = "item_#{SecureRandom.hex(8)}"
30
30
  conn.exec_params(
31
- "INSERT INTO llmemory_items (id, user_id, category, content, source_resource_id, importance, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)",
32
- [id, user_id, category, content, source_resource_id, importance.to_f, Time.now.utc.iso8601]
31
+ "INSERT INTO llmemory_items (id, user_id, category, content, source_resource_id, importance, provenance, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)",
32
+ [id, user_id, category, content, source_resource_id, importance.to_f, provenance ? JSON.generate(provenance) : nil, Time.now.utc.iso8601]
33
33
  )
34
34
  id
35
35
  end
@@ -67,7 +67,7 @@ module Llmemory
67
67
  ensure_tables!
68
68
  pattern = "%#{conn.escape_string(query.to_s.downcase)}%"
69
69
  rows = conn.exec_params(
70
- "SELECT id, category, content, source_resource_id, importance, created_at FROM llmemory_items WHERE user_id = $1 AND LOWER(content) LIKE $2",
70
+ "SELECT id, category, content, source_resource_id, importance, provenance, created_at FROM llmemory_items WHERE user_id = $1 AND LOWER(content) LIKE $2",
71
71
  [user_id, pattern]
72
72
  )
73
73
  rows_to_items(rows)
@@ -97,7 +97,7 @@ module Llmemory
97
97
  ensure_tables!
98
98
  cutoff = (Time.now - (days * 86400)).utc.iso8601
99
99
  rows = conn.exec_params(
100
- "SELECT id, category, content, source_resource_id, importance, created_at FROM llmemory_items WHERE user_id = $1 AND created_at < $2 ORDER BY created_at",
100
+ "SELECT id, category, content, source_resource_id, importance, provenance, created_at FROM llmemory_items WHERE user_id = $1 AND created_at < $2 ORDER BY created_at",
101
101
  [user_id, cutoff]
102
102
  )
103
103
  rows_to_items(rows)
@@ -106,7 +106,7 @@ module Llmemory
106
106
  def get_all_items(user_id)
107
107
  ensure_tables!
108
108
  rows = conn.exec_params(
109
- "SELECT id, category, content, source_resource_id, importance, created_at FROM llmemory_items WHERE user_id = $1 ORDER BY created_at",
109
+ "SELECT id, category, content, source_resource_id, importance, provenance, created_at FROM llmemory_items WHERE user_id = $1 ORDER BY created_at",
110
110
  [user_id]
111
111
  )
112
112
  rows_to_items(rows)
@@ -125,7 +125,7 @@ module Llmemory
125
125
  ensure_tables!
126
126
  cutoff = (Time.now - (hours * 3600)).utc.iso8601
127
127
  rows = conn.exec_params(
128
- "SELECT id, category, content, source_resource_id, importance, created_at FROM llmemory_items WHERE user_id = $1 AND created_at >= $2 ORDER BY created_at",
128
+ "SELECT id, category, content, source_resource_id, importance, provenance, created_at FROM llmemory_items WHERE user_id = $1 AND created_at >= $2 ORDER BY created_at",
129
129
  [user_id, cutoff]
130
130
  )
131
131
  rows_to_items(rows)
@@ -179,7 +179,7 @@ module Llmemory
179
179
 
180
180
  def list_items(user_id:, category: nil, limit: nil)
181
181
  ensure_tables!
182
- sql = "SELECT id, category, content, source_resource_id, importance, created_at FROM llmemory_items WHERE user_id = $1"
182
+ sql = "SELECT id, category, content, source_resource_id, importance, provenance, created_at FROM llmemory_items WHERE user_id = $1"
183
183
  params = [user_id]
184
184
  if category
185
185
  sql += " AND category = $2"
@@ -258,11 +258,13 @@ module Llmemory
258
258
  content TEXT NOT NULL,
259
259
  source_resource_id TEXT,
260
260
  importance REAL DEFAULT 0.7,
261
+ provenance JSONB,
261
262
  created_at TIMESTAMPTZ NOT NULL
262
263
  );
263
264
  CREATE INDEX IF NOT EXISTS idx_llmemory_items_user_id ON llmemory_items(user_id);
264
265
  SQL
265
266
  conn.exec("ALTER TABLE llmemory_items ADD COLUMN IF NOT EXISTS importance REAL DEFAULT 0.7") rescue nil
267
+ conn.exec("ALTER TABLE llmemory_items ADD COLUMN IF NOT EXISTS provenance JSONB") rescue nil
266
268
  conn.exec(<<~SQL)
267
269
  CREATE TABLE IF NOT EXISTS llmemory_categories (
268
270
  user_id TEXT NOT NULL,
@@ -282,11 +284,19 @@ module Llmemory
282
284
  content: r["content"],
283
285
  source_resource_id: r["source_resource_id"],
284
286
  importance: (r["importance"] || 0.7).to_f,
287
+ provenance: parse_provenance(r["provenance"]),
285
288
  created_at: Time.parse(r["created_at"])
286
289
  }
287
290
  end
288
291
  end
289
292
 
293
+ def parse_provenance(value)
294
+ return nil if value.nil? || value.to_s.strip.empty?
295
+ JSON.parse(value, symbolize_names: true)
296
+ rescue JSON::ParserError
297
+ nil
298
+ end
299
+
290
300
  def rows_to_resources(rows)
291
301
  rows.map do |r|
292
302
  {
@@ -24,7 +24,7 @@ module Llmemory
24
24
  id
25
25
  end
26
26
 
27
- def save_item(user_id, category:, content:, source_resource_id:, importance: 0.7)
27
+ def save_item(user_id, category:, content:, source_resource_id:, importance: 0.7, provenance: nil)
28
28
  ensure_user_dir(user_id)
29
29
  seq = next_seq(user_id, "item_id_seq")
30
30
  id = "item_#{seq}"
@@ -35,6 +35,7 @@ module Llmemory
35
35
  content: content,
36
36
  source_resource_id: source_resource_id,
37
37
  importance: importance,
38
+ provenance: provenance,
38
39
  created_at: Time.now.iso8601
39
40
  }
40
41
  File.write(path, JSON.generate(data))
@@ -22,7 +22,7 @@ module Llmemory
22
22
  id
23
23
  end
24
24
 
25
- def save_item(user_id, category:, content:, source_resource_id:, importance: 0.7)
25
+ def save_item(user_id, category:, content:, source_resource_id:, importance: 0.7, provenance: nil)
26
26
  @item_id_seq += 1
27
27
  id = "item_#{@item_id_seq}"
28
28
  @items[user_id] << {
@@ -31,6 +31,7 @@ module Llmemory
31
31
  content: content,
32
32
  source_resource_id: source_resource_id,
33
33
  importance: importance,
34
+ provenance: provenance,
34
35
  created_at: Time.now
35
36
  }
36
37
  id
@@ -31,6 +31,13 @@ module Llmemory
31
31
  !archived_at.nil?
32
32
  end
33
33
 
34
+ # Lineage of this edge, stored within properties so it round-trips
35
+ # through every backend without a schema change. See Llmemory::Provenance.
36
+ def provenance
37
+ props = properties || {}
38
+ props[:provenance] || props["provenance"]
39
+ end
40
+
34
41
  def to_h
35
42
  {
36
43
  id: id,
@@ -32,6 +32,7 @@ module Llmemory
32
32
 
33
33
  return true if entities.empty? && relations.empty?
34
34
 
35
+ provenance = Llmemory::Provenance.from_text_fingerprint(text, method: "entity_relation_extraction")
35
36
  name_to_id = {}
36
37
 
37
38
  entities.each do |e|
@@ -39,7 +40,7 @@ module Llmemory
39
40
  entity_type = e[:type] || e["type"] || "concept"
40
41
  name = e[:name] || e["name"]
41
42
  next if name.nil? || name.to_s.strip.empty?
42
- id = @kg.add_node(entity_type: entity_type, name: name.to_s.strip, properties: {})
43
+ id = @kg.add_node(entity_type: entity_type, name: name.to_s.strip, properties: { "provenance" => provenance })
43
44
  name_to_id[name.to_s.strip] ||= id
44
45
  end
45
46
 
@@ -50,8 +51,8 @@ module Llmemory
50
51
  object = (r[:object] || r["object"]).to_s.strip
51
52
  next if subject.empty? || predicate.empty? || object.empty?
52
53
 
53
- subject_id = name_to_id[subject] || @kg.add_node(entity_type: "concept", name: subject, properties: {})
54
- object_id = name_to_id[object] || @kg.add_node(entity_type: "concept", name: object, properties: {})
54
+ subject_id = name_to_id[subject] || @kg.add_node(entity_type: "concept", name: subject, properties: { "provenance" => provenance })
55
+ object_id = name_to_id[object] || @kg.add_node(entity_type: "concept", name: object, properties: { "provenance" => provenance })
55
56
 
56
57
  edge = Edge.new(
57
58
  id: nil,
@@ -59,12 +60,12 @@ module Llmemory
59
60
  subject_id: subject_id,
60
61
  predicate: predicate,
61
62
  target_id: object_id,
62
- properties: {},
63
+ properties: { "provenance" => provenance },
63
64
  created_at: Time.now,
64
65
  archived_at: nil
65
66
  )
66
67
  @conflict_resolver.resolve(edge)
67
- edge_id = @kg.add_edge(subject: subject_id, predicate: predicate, object: object_id, properties: {})
68
+ edge_id = @kg.add_edge(subject: subject_id, predicate: predicate, object: object_id, properties: { "provenance" => provenance })
68
69
 
69
70
  text = "#{subject} #{predicate} #{object}"
70
71
  embedding = @vector_store.respond_to?(:embed) ? @vector_store.embed(text) : nil
@@ -89,7 +90,8 @@ module Llmemory
89
90
  {
90
91
  text: r[:text],
91
92
  timestamp: r[:created_at] || r[:timestamp],
92
- score: r[:score] || 1.0
93
+ score: r[:score] || 1.0,
94
+ importance: r[:importance]
93
95
  }
94
96
  end
95
97
  end
@@ -25,6 +25,13 @@ module Llmemory
25
25
  )
26
26
  end
27
27
 
28
+ # Lineage of this node, stored within properties so it round-trips
29
+ # through every backend without a schema change. See Llmemory::Provenance.
30
+ def provenance
31
+ props = properties || {}
32
+ props[:provenance] || props["provenance"]
33
+ end
34
+
28
35
  def to_h
29
36
  {
30
37
  id: id,
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Llmemory
6
+ # Provenance records the lineage of a long-term memory item: where it came
7
+ # from, how it was produced, and with what confidence. It is stored as a
8
+ # plain JSON-safe Hash so it round-trips through every storage backend
9
+ # (in-memory, JSON files, SQL columns, jsonb properties) without coupling.
10
+ #
11
+ # Shape: { sources: [{ type:, id: }], method:, confidence:, created_at: }
12
+ #
13
+ # `method` identifies the producing process (e.g. "fact_extraction",
14
+ # "entity_relation_extraction", and in the future "reflection"), so a
15
+ # semantic datum can always be traced back to its raw source.
16
+ module Provenance
17
+ module_function
18
+
19
+ def build(method:, sources: [], confidence: nil, created_at: nil)
20
+ {
21
+ sources: Array(sources).filter_map { |s| normalize_source(s) },
22
+ method: method&.to_s,
23
+ confidence: confidence.nil? ? nil : confidence.to_f,
24
+ created_at: normalize_time(created_at)
25
+ }
26
+ end
27
+
28
+ # Convenience for the file-based path, where the raw text is persisted as a
29
+ # Resource and referenced by id.
30
+ def from_resource(resource_id, method:, confidence: nil, created_at: nil)
31
+ sources = resource_id ? [{ type: "resource", id: resource_id }] : []
32
+ build(method: method, sources: sources, confidence: confidence, created_at: created_at)
33
+ end
34
+
35
+ # Convenience for the graph-based path, which does not persist the raw text.
36
+ # We record a stable fingerprint of the source instead of the document
37
+ # itself, keeping lineage verifiable without exposing sensitive content.
38
+ def from_text_fingerprint(text, method:, confidence: nil, created_at: nil)
39
+ require "digest"
40
+ sources = []
41
+ unless text.to_s.strip.empty?
42
+ sources = [{ type: "text_sha256", id: Digest::SHA256.hexdigest(text.to_s)[0, 16] }]
43
+ end
44
+ build(method: method, sources: sources, confidence: confidence, created_at: created_at)
45
+ end
46
+
47
+ def normalize_source(source)
48
+ return nil if source.nil?
49
+ if source.is_a?(Hash)
50
+ type = source[:type] || source["type"]
51
+ id = source[:id] || source["id"]
52
+ return nil if id.nil?
53
+ { type: type.nil? ? "unknown" : type.to_s, id: id }
54
+ else
55
+ { type: "unknown", id: source }
56
+ end
57
+ end
58
+
59
+ def normalize_time(value)
60
+ value ||= Time.now
61
+ value.respond_to?(:iso8601) ? value.iso8601 : value.to_s
62
+ end
63
+ end
64
+ end
@@ -54,6 +54,7 @@ module Llmemory
54
54
  text: c[:text] || c["text"],
55
55
  timestamp: parse_timestamp(c[:timestamp] || c["timestamp"] || c[:created_at] || c["created_at"]),
56
56
  score: (c[:score] || c["score"] || 1.0).to_f,
57
+ importance: c[:importance] || c["importance"],
57
58
  evergreen: c[:evergreen] || c["evergreen"]
58
59
  }
59
60
  end
@@ -3,12 +3,14 @@
3
3
  module Llmemory
4
4
  module Retrieval
5
5
  class TemporalRanker
6
- def initialize(half_life_days: nil)
6
+ def initialize(half_life_days: nil, importance_weight: nil)
7
7
  @half_life_days = half_life_days || Llmemory.configuration.time_decay_half_life_days
8
+ @importance_weight = importance_weight || Llmemory.configuration.importance_weight
8
9
  end
9
10
 
10
11
  def rank(candidates, now: Time.now)
11
12
  lambda_val = Math.log(2) / @half_life_days.to_f
13
+ weight = [@importance_weight.to_f, 0.0].max
12
14
 
13
15
  candidates.map do |c|
14
16
  score = (c[:score] || c["score"] || 1.0).to_f
@@ -22,10 +24,22 @@ module Llmemory
22
24
  Math.exp(-lambda_val * age_days.to_f)
23
25
  end
24
26
 
25
- final_score = score * time_decay
26
- c.merge(score: score, temporal_score: final_score, timestamp: timestamp)
27
+ importance = normalize_importance(c[:importance] || c["importance"])
28
+ importance_factor = importance**weight
29
+
30
+ final_score = score * time_decay * importance_factor
31
+ c.merge(score: score, importance: importance, temporal_score: final_score, timestamp: timestamp)
27
32
  end.sort_by { |c| -(c[:temporal_score] || 0) }
28
33
  end
34
+
35
+ private
36
+
37
+ # Missing importance is neutral (1.0) so candidates that carry no
38
+ # importance signal (resources, graph edges) are never penalised.
39
+ def normalize_importance(value)
40
+ return 1.0 if value.nil?
41
+ [[value.to_f, 0.0].max, 1.0].min
42
+ end
29
43
  end
30
44
  end
31
45
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Llmemory
4
- VERSION = "0.1.15"
4
+ VERSION = "0.1.16"
5
5
  end
data/lib/llmemory.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "llmemory/version"
4
4
  require_relative "llmemory/configuration"
5
+ require_relative "llmemory/provenance"
5
6
  require_relative "llmemory/llm"
6
7
  require_relative "llmemory/short_term"
7
8
  require_relative "llmemory/long_term"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llmemory
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.15
4
+ version: 0.1.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - llmemory
@@ -195,6 +195,7 @@ files:
195
195
  - lib/llmemory/mcp/tools/memory_timeline_context.rb
196
196
  - lib/llmemory/memory.rb
197
197
  - lib/llmemory/noise_filter.rb
198
+ - lib/llmemory/provenance.rb
198
199
  - lib/llmemory/retrieval.rb
199
200
  - lib/llmemory/retrieval/bm25_scorer.rb
200
201
  - lib/llmemory/retrieval/context_assembler.rb
@@ -240,7 +241,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
240
241
  - !ruby/object:Gem::Version
241
242
  version: '0'
242
243
  requirements: []
243
- rubygems_version: 4.0.3
244
+ rubygems_version: 4.0.10
244
245
  specification_version: 4
245
246
  summary: Persistent memory system for LLM agents
246
247
  test_files: []