llmemory 0.2.10 → 0.3.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +48 -0
  3. data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +71 -0
  4. data/lib/generators/llmemory/upgrade_zero_mem/templates/create_llmemory_zero_mem_tables.rb +77 -0
  5. data/lib/generators/llmemory/upgrade_zero_mem/upgrade_zero_mem_generator.rb +19 -0
  6. data/lib/llmemory/cli/commands/zero_mem.rb +107 -0
  7. data/lib/llmemory/cli.rb +3 -0
  8. data/lib/llmemory/configuration.rb +67 -1
  9. data/lib/llmemory/consolidation/filter.rb +74 -0
  10. data/lib/llmemory/consolidation/policy.rb +17 -0
  11. data/lib/llmemory/consolidation/rule_based_policy.rb +63 -0
  12. data/lib/llmemory/consolidation/unit.rb +54 -0
  13. data/lib/llmemory/consolidation.rb +11 -0
  14. data/lib/llmemory/instrumentation.rb +10 -1
  15. data/lib/llmemory/long_term/file_based/memory.rb +18 -7
  16. data/lib/llmemory/long_term/graph_based/memory.rb +31 -8
  17. data/lib/llmemory/maintenance/cognitive_pass.rb +29 -1
  18. data/lib/llmemory/maintenance/policy_prune.rb +85 -0
  19. data/lib/llmemory/maintenance/zero_mem_ttl.rb +26 -0
  20. data/lib/llmemory/maintenance.rb +2 -0
  21. data/lib/llmemory/mcp/server.rb +2 -0
  22. data/lib/llmemory/mcp/store_helpers.rb +16 -0
  23. data/lib/llmemory/mcp/tools/memory_add_message.rb +17 -4
  24. data/lib/llmemory/mcp/tools/memory_consolidate.rb +9 -4
  25. data/lib/llmemory/mcp/tools/memory_maintain.rb +6 -1
  26. data/lib/llmemory/mcp/tools/memory_retrieve.rb +3 -1
  27. data/lib/llmemory/mcp/tools/memory_retrieve_evidence.rb +70 -0
  28. data/lib/llmemory/mcp/tools/memory_stats.rb +22 -0
  29. data/lib/llmemory/memory.rb +257 -9
  30. data/lib/llmemory/retrieval/bm25_scorer.rb +5 -0
  31. data/lib/llmemory/retrieval/context_assembler.rb +17 -1
  32. data/lib/llmemory/tokenizer.rb +18 -8
  33. data/lib/llmemory/vector_store/memory_store.rb +23 -1
  34. data/lib/llmemory/vector_store/openai_embeddings.rb +47 -21
  35. data/lib/llmemory/version.rb +1 -1
  36. data/lib/llmemory/zero_mem/answer_calibrator.rb +63 -0
  37. data/lib/llmemory/zero_mem/backfill.rb +43 -0
  38. data/lib/llmemory/zero_mem/budget_assembler.rb +62 -0
  39. data/lib/llmemory/zero_mem/circuit_breaker.rb +37 -0
  40. data/lib/llmemory/zero_mem/embedding_provider.rb +73 -0
  41. data/lib/llmemory/zero_mem/engine.rb +247 -0
  42. data/lib/llmemory/zero_mem/entity.rb +31 -0
  43. data/lib/llmemory/zero_mem/entity_extractor.rb +23 -0
  44. data/lib/llmemory/zero_mem/entity_index.rb +65 -0
  45. data/lib/llmemory/zero_mem/entity_mention.rb +35 -0
  46. data/lib/llmemory/zero_mem/errors.rb +7 -0
  47. data/lib/llmemory/zero_mem/evidence.rb +43 -0
  48. data/lib/llmemory/zero_mem/evidence_calibrator.rb +158 -0
  49. data/lib/llmemory/zero_mem/evidence_closure.rb +98 -0
  50. data/lib/llmemory/zero_mem/evidence_fusion.rb +57 -0
  51. data/lib/llmemory/zero_mem/evidence_set.rb +54 -0
  52. data/lib/llmemory/zero_mem/extractors/heuristic.rb +44 -0
  53. data/lib/llmemory/zero_mem/extractors/http.rb +58 -0
  54. data/lib/llmemory/zero_mem/graph_retriever.rb +107 -0
  55. data/lib/llmemory/zero_mem/hierarchy_builder.rb +123 -0
  56. data/lib/llmemory/zero_mem/hierarchy_retriever.rb +133 -0
  57. data/lib/llmemory/zero_mem/indexer.rb +73 -0
  58. data/lib/llmemory/zero_mem/mode.rb +30 -0
  59. data/lib/llmemory/zero_mem/pagerank.rb +42 -0
  60. data/lib/llmemory/zero_mem/query_profile.rb +48 -0
  61. data/lib/llmemory/zero_mem/query_profiler.rb +138 -0
  62. data/lib/llmemory/zero_mem/repair.rb +31 -0
  63. data/lib/llmemory/zero_mem/router.rb +69 -0
  64. data/lib/llmemory/zero_mem/sidecar_client.rb +82 -0
  65. data/lib/llmemory/zero_mem/storage.rb +124 -0
  66. data/lib/llmemory/zero_mem/storages/active_record.rb +311 -0
  67. data/lib/llmemory/zero_mem/storages/active_record_models.rb +21 -0
  68. data/lib/llmemory/zero_mem/storages/file.rb +67 -0
  69. data/lib/llmemory/zero_mem/storages/memory.rb +294 -0
  70. data/lib/llmemory/zero_mem/storages/postgres.rb +85 -0
  71. data/lib/llmemory/zero_mem/storages/snapshot_codec.rb +180 -0
  72. data/lib/llmemory/zero_mem/storages.rb +43 -0
  73. data/lib/llmemory/zero_mem/trace.rb +100 -0
  74. data/lib/llmemory/zero_mem/trace_state_link.rb +64 -0
  75. data/lib/llmemory/zero_mem/trace_unit.rb +83 -0
  76. data/lib/llmemory/zero_mem.rb +42 -0
  77. data/lib/llmemory.rb +8 -0
  78. data/lib/tasks/llmemory.rake +84 -2
  79. metadata +54 -2
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Llmemory
6
+ module ZeroMem
7
+ class Backfill
8
+ WARNING = "Backfill only covers messages still present in the checkpoint; " \
9
+ "text removed by prior LLM compaction cannot be reconstructed."
10
+
11
+ def initialize(storage:, indexer: nil)
12
+ @storage = storage
13
+ @indexer = indexer || Indexer.new(storage)
14
+ end
15
+
16
+ def run!(user_id:, session_id:, messages:, id_prefix: "backfill")
17
+ created = []
18
+ Array(messages).each_with_index do |msg, idx|
19
+ role = msg[:role] || msg["role"]
20
+ content = msg[:content] || msg["content"]
21
+ next if content.to_s.empty?
22
+
23
+ sequence = @storage.next_sequence(user_id, session_id)
24
+ trace = Trace.build(
25
+ user_id: user_id,
26
+ session_id: session_id,
27
+ role: role,
28
+ content: content,
29
+ sequence: sequence,
30
+ idempotency_key: "#{id_prefix}-#{idx}-#{Digest::SHA256.hexdigest(content.to_s)[0, 12]}"
31
+ )
32
+ next if @storage.find_by_idempotency_key(user_id, trace.idempotency_key)
33
+
34
+ @storage.write_trace(trace)
35
+ @indexer.after_trace_write(trace: trace)
36
+ created << trace.id
37
+ end
38
+
39
+ { created_trace_ids: created, warning: WARNING }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class BudgetAssembler
6
+ RESERVE_TOKENS = 64
7
+
8
+ def assemble(evidence, max_tokens:, local_reserve: RESERVE_TOKENS, atomic_groups: nil)
9
+ budget = max_tokens&.to_i
10
+ return evidence if budget.nil? || budget <= 0
11
+
12
+ reserve = [local_reserve, budget / 4].min
13
+ remaining = budget - reserve
14
+ selected = []
15
+ used_ids = {}
16
+
17
+ groups = build_atomic_groups(evidence, atomic_groups)
18
+ ungrouped = evidence.reject { |ev| groups.any? { |g| g.include?(ev.trace_id) } }
19
+
20
+ groups.each do |group|
21
+ members = group.map { |id| evidence.find { |ev| ev.trace_id == id } }.compact
22
+ cost = members.sum { |ev| token_cost(ev) }
23
+ next if cost > remaining
24
+
25
+ members.each do |ev|
26
+ selected << ev
27
+ used_ids[ev.trace_id] = true
28
+ end
29
+ remaining -= cost
30
+ end
31
+
32
+ ungrouped.each do |ev|
33
+ next if used_ids[ev.trace_id]
34
+
35
+ cost = token_cost(ev)
36
+ next if cost > remaining && !selected.empty?
37
+
38
+ if cost <= remaining
39
+ selected << ev
40
+ remaining -= cost
41
+ end
42
+ end
43
+
44
+ selected
45
+ end
46
+
47
+ def build_atomic_groups(evidence, explicit_groups)
48
+ ids = evidence.map(&:trace_id)
49
+ Array(explicit_groups).map do |group|
50
+ Array(group).map(&:to_s).select { |id| ids.include?(id) }
51
+ end.reject(&:empty?)
52
+ end
53
+
54
+ private
55
+
56
+ def token_cost(evidence)
57
+ line = "[#{evidence.trace_id}] #{evidence.role}: #{evidence.content}"
58
+ (Llmemory::Tokenizer.tokenize(line).size + 4)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class CircuitBreaker
6
+ def initialize(failure_threshold: 3, cool_down_seconds: 30)
7
+ @failure_threshold = failure_threshold.to_i
8
+ @cool_down_seconds = cool_down_seconds.to_i
9
+ @failures = 0
10
+ @opened_at = nil
11
+ end
12
+
13
+ def allow_request?
14
+ return true if @opened_at.nil?
15
+ return false if (Time.now - @opened_at) < @cool_down_seconds
16
+
17
+ @opened_at = nil
18
+ @failures = 0
19
+ true
20
+ end
21
+
22
+ def record_success
23
+ @failures = 0
24
+ @opened_at = nil
25
+ end
26
+
27
+ def record_failure
28
+ @failures += 1
29
+ @opened_at = Time.now if @failures >= @failure_threshold
30
+ end
31
+
32
+ def open?
33
+ !allow_request?
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ module EmbeddingProvider
6
+ def embed(texts)
7
+ raise NotImplementedError
8
+ end
9
+
10
+ def model
11
+ raise NotImplementedError
12
+ end
13
+
14
+ def dimensions
15
+ raise NotImplementedError
16
+ end
17
+
18
+ def local?
19
+ false
20
+ end
21
+
22
+ def last_usage
23
+ Llmemory::LLM::Usage.zero
24
+ end
25
+
26
+ def embed_one(text)
27
+ embed([text]).first
28
+ end
29
+ end
30
+
31
+ # Deterministic vectors for tests (no HTTP).
32
+ class FakeEmbeddingProvider
33
+ include EmbeddingProvider
34
+
35
+ attr_reader :calls
36
+
37
+ def initialize(dimensions: 8, model: "fake-embed")
38
+ @dimensions = dimensions
39
+ @model = model
40
+ @calls = 0
41
+ end
42
+
43
+ def embed(texts)
44
+ @calls += 1
45
+ Array(texts).map { |text| vector_for(text) }
46
+ end
47
+
48
+ def model
49
+ @model
50
+ end
51
+
52
+ def dimensions
53
+ @dimensions
54
+ end
55
+
56
+ def local?
57
+ true
58
+ end
59
+
60
+ private
61
+
62
+ def vector_for(text)
63
+ seed = Digest::SHA256.hexdigest(text.to_s)
64
+ @dimensions.times.map do |i|
65
+ byte = seed.bytes[i % seed.bytes.length] || 0
66
+ (byte / 255.0)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ require "digest"
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../retrieval/bm25_scorer"
4
+
5
+ module Llmemory
6
+ module ZeroMem
7
+ class Engine
8
+ def initialize(trace_store:, user_id:, config: nil, profiler: nil, retriever: nil,
9
+ graph_retriever: nil, calibrator: nil, assembler: nil, entity_index: nil,
10
+ embedding_provider: nil, router: nil, fusion: nil, closure: nil, **options)
11
+ if options.key?(:llm) || options.key?("llm")
12
+ raise ArgumentError, "ZeroMem::Engine does not accept an llm client"
13
+ end
14
+
15
+ @storage = trace_store
16
+ @user_id = user_id
17
+ @config = config || Llmemory.configuration
18
+ @profiler = profiler || QueryProfiler.new
19
+ @retriever = retriever || HierarchyRetriever.new(@storage, config: @config)
20
+ @entity_index = entity_index || EntityIndex.new(@storage)
21
+ @embedding_provider = embedding_provider
22
+ @graph_retriever = graph_retriever || GraphRetriever.new(
23
+ @storage,
24
+ entity_index: @entity_index,
25
+ embedding_provider: @embedding_provider,
26
+ config: @config
27
+ )
28
+ @calibrator = calibrator || EvidenceCalibrator.new(@storage)
29
+ @assembler = assembler || BudgetAssembler.new
30
+ @router = router || Router.new
31
+ @fusion = fusion || EvidenceFusion.new
32
+ @closure = closure || EvidenceClosure.new(@storage, config: @config)
33
+ @bm25 = Retrieval::Bm25Scorer.new
34
+ end
35
+
36
+ def retrieve_evidence(query, top_k: nil, max_tokens: nil, boundary: nil, explain: false,
37
+ current_trace_id: nil, skip_closure: false, skip_calibration: false,
38
+ fusion_weights: nil)
39
+ effective_k = effective_top_k(top_k, nil)
40
+ metrics = { llm_calls: 0, encoder_calls: 0, encoder_duration_ms: 0.0, stages: {} }
41
+
42
+ profile = nil
43
+ Llmemory::Instrumentation.instrument(:zero_mem_profile, user_id: @user_id, query_chars: query.to_s.length) do
44
+ profile = @profiler.profile(query, boundary: boundary)
45
+ end
46
+ effective_k = effective_top_k(top_k, profile)
47
+
48
+ retrieval = nil
49
+ Llmemory::Instrumentation.instrument(:zero_mem_hierarchy_retrieve, user_id: @user_id, workload: profile.workload_class) do
50
+ retrieval = @retriever.retrieve(user_id: @user_id, query: query, profile: profile, top_k: effective_k)
51
+ end
52
+
53
+ graph = nil
54
+ Llmemory::Instrumentation.instrument(:zero_mem_graph_retrieve, user_id: @user_id) do
55
+ graph = @graph_retriever.retrieve(user_id: @user_id, query: query, profile: profile, top_k: effective_k)
56
+ end
57
+
58
+ degraded = (retrieval[:degraded] + graph[:degraded]).uniq
59
+ degraded << :dense unless @embedding_provider
60
+ degraded.uniq!
61
+
62
+ hierarchy_scores = hierarchy_trace_scores(query, retrieval[:traces])
63
+ graph_scores = graph[:trace_scores] || {}
64
+ routing = @router.route(profile, hierarchy_scores: hierarchy_scores, graph_scores: graph_scores, config: @config)
65
+ weights = fusion_weights || routing[:weights]
66
+
67
+ fused = @fusion.fuse(graph_scores: graph_scores, hierarchy_scores: hierarchy_scores, weights: weights)
68
+ seed_rows = fused.first(effective_k)
69
+ seed_ids = seed_rows.map { |r| r[:trace_id] }
70
+
71
+ closure_result = if skip_closure
72
+ { seed_trace_ids: seed_ids, closure_trace_ids: [], bridges: [], atomic_groups: [] }
73
+ else
74
+ Llmemory::Instrumentation.instrument(:zero_mem_closure, user_id: @user_id, seeds: seed_ids.size) do
75
+ @closure.expand(
76
+ user_id: @user_id,
77
+ profile: profile,
78
+ seed_trace_ids: seed_ids,
79
+ fused_ranking: fused
80
+ )
81
+ end
82
+ end
83
+
84
+ ordered_ids = (seed_ids + closure_result[:closure_trace_ids]).uniq
85
+ traces = ordered_ids.map { |id| @storage.get_trace(@user_id, id) }.compact
86
+
87
+ calibration = if skip_calibration
88
+ { traces: traces, excluded: [], conflict_trace_ids: [], seed_ids: seed_ids,
89
+ closure_ids: closure_result[:closure_trace_ids] }
90
+ else
91
+ Llmemory::Instrumentation.instrument(:zero_mem_calibrate, user_id: @user_id, trace_count: traces.size) do
92
+ @calibrator.calibrate(
93
+ user_id: @user_id,
94
+ traces: traces,
95
+ profile: profile,
96
+ current_trace_id: current_trace_id,
97
+ fusion_rows: fused,
98
+ seed_ids: seed_ids,
99
+ closure_ids: closure_result[:closure_trace_ids]
100
+ )
101
+ end
102
+ end
103
+
104
+ fusion_by_id = fused.each_with_object({}) { |row, acc| acc[row[:trace_id].to_s] = row }
105
+ conflict_ids = calibration[:conflict_trace_ids].to_h { |id| [id.to_s, true] }
106
+
107
+ evidence = calibration[:traces].map do |trace|
108
+ row = fusion_by_id[trace.id]
109
+ score = row ? row[:final] : 0.5
110
+ Evidence.new(
111
+ trace_id: trace.id,
112
+ content: trace.content,
113
+ role: trace.role,
114
+ session_id: trace.session_id,
115
+ occurred_at: trace.occurred_at,
116
+ confidence: score.clamp(0.0, 1.0),
117
+ score: score.clamp(0.0, 1.0),
118
+ sources: row ? row[:sources] : [],
119
+ conflict: conflict_ids[trace.id] == true,
120
+ seed: seed_ids.include?(trace.id),
121
+ closure: closure_result[:closure_trace_ids].include?(trace.id),
122
+ content_sha256: trace.content_sha256
123
+ )
124
+ end
125
+
126
+ seed_evidence = evidence.select(&:seed)
127
+ closure_evidence = evidence.select(&:closure)
128
+
129
+ packed = @assembler.assemble(
130
+ evidence,
131
+ max_tokens: max_tokens,
132
+ atomic_groups: closure_result[:atomic_groups]
133
+ )
134
+
135
+ explain_payload = if explain
136
+ build_explain(
137
+ profile: profile,
138
+ routing: routing,
139
+ weights: weights,
140
+ effective_k: effective_k,
141
+ graph: graph,
142
+ retrieval: retrieval,
143
+ fused: fused,
144
+ calibration: calibration,
145
+ closure_result: closure_result,
146
+ degraded: degraded,
147
+ warnings: graph[:warnings]
148
+ )
149
+ end
150
+
151
+ result = EvidenceSet.new(
152
+ route: routing[:route],
153
+ profile: profile,
154
+ evidence: packed,
155
+ seed_evidence: seed_evidence.select { |ev| packed.any? { |p| p.trace_id == ev.trace_id } },
156
+ closure_evidence: closure_evidence.select { |ev| packed.any? { |p| p.trace_id == ev.trace_id } },
157
+ metrics: metrics.merge(
158
+ degraded: degraded,
159
+ seed_recall: seed_evidence.map(&:trace_id),
160
+ closure_recall: (seed_evidence + closure_evidence).map(&:trace_id).uniq
161
+ ),
162
+ explain: explain_payload,
163
+ degraded: degraded,
164
+ relational_trace_scores: graph_scores,
165
+ hierarchy_traces: retrieval[:traces],
166
+ relational_traces: graph[:traces],
167
+ warnings: graph[:warnings]
168
+ )
169
+
170
+ Llmemory::Instrumentation.instrument(
171
+ :zero_mem_retrieve,
172
+ user_id: @user_id,
173
+ evidence_count: packed.size,
174
+ degraded: degraded,
175
+ seed_count: seed_evidence.size,
176
+ closure_count: closure_evidence.size
177
+ )
178
+
179
+ result
180
+ end
181
+
182
+ def to_context(query, **opts)
183
+ retrieve_evidence(query, **opts).to_context
184
+ end
185
+
186
+ private
187
+
188
+ def effective_top_k(explicit, profile)
189
+ base = explicit || @config.zero_mem_top_k
190
+ need = profile ? [base, profile.expected_evidence_count].max : base
191
+ [need, @config.zero_mem_max_top_k].min
192
+ end
193
+
194
+ def hierarchy_trace_scores(query, traces)
195
+ docs = traces.map { |t| { id: t.id, text: t.content } }
196
+ return {} if docs.empty?
197
+
198
+ @bm25.score_documents(query, docs).to_h { |d| [d[:id], d[:normalized_bm25].to_f] }
199
+ end
200
+
201
+ def build_explain(profile:, routing:, weights:, effective_k:, graph:, retrieval:, fused:,
202
+ calibration:, closure_result:, degraded:, warnings:)
203
+ {
204
+ profile: profile.to_h,
205
+ route: routing[:route],
206
+ weights: weights,
207
+ thresholds: {
208
+ rho: routing[:rho],
209
+ gamma: @config.zero_mem_pagerank_damping,
210
+ top_k: effective_k
211
+ },
212
+ graph: {
213
+ seeds: graph[:trace_scores]&.keys&.first(5),
214
+ nodes_visited: graph[:traces]&.size,
215
+ truncated: warnings.include?(:pagerank_truncated),
216
+ boosts: []
217
+ },
218
+ hierarchy: {
219
+ episodes: retrieval[:stage_scores][:episodes],
220
+ windows: retrieval[:stage_scores][:windows],
221
+ turns: retrieval[:stage_scores][:turns]
222
+ },
223
+ fusion: {
224
+ per_trace: fused.first(effective_k).map do |row|
225
+ {
226
+ id: row[:trace_id],
227
+ graph: row[:graph],
228
+ hierarchy: row[:hierarchy],
229
+ final: row[:final],
230
+ sources: row[:sources]
231
+ }
232
+ end
233
+ },
234
+ included: calibration[:traces].map(&:id),
235
+ excluded: calibration[:excluded],
236
+ closure: {
237
+ seed_ids: closure_result[:seed_trace_ids],
238
+ closure_ids: closure_result[:closure_trace_ids],
239
+ bridges: closure_result[:bridges]
240
+ },
241
+ warnings: warnings,
242
+ degraded: degraded
243
+ }
244
+ end
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Llmemory
6
+ module ZeroMem
7
+ class Entity
8
+ attr_reader :id, :user_id, :normalized_key, :display_name, :entity_type, :ambiguous
9
+
10
+ def self.build(user_id:, normalized_key:, display_name: nil, entity_type: :entity, id: nil, ambiguous: false)
11
+ new(
12
+ id: id || "ent_#{Digest::SHA256.hexdigest([user_id, normalized_key].join(':'))[0, 16]}",
13
+ user_id: user_id.to_s,
14
+ normalized_key: normalized_key.to_s,
15
+ display_name: display_name || normalized_key.to_s,
16
+ entity_type: entity_type.to_sym,
17
+ ambiguous: ambiguous
18
+ )
19
+ end
20
+
21
+ def initialize(id:, user_id:, normalized_key:, display_name:, entity_type:, ambiguous:)
22
+ @id = id
23
+ @user_id = user_id
24
+ @normalized_key = normalized_key
25
+ @display_name = display_name
26
+ @entity_type = entity_type
27
+ @ambiguous = ambiguous
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class EntityExtractor
6
+ def extract(_text)
7
+ raise NotImplementedError
8
+ end
9
+
10
+ def generative?
11
+ false
12
+ end
13
+
14
+ def name
15
+ self.class.name.split("::").last.downcase
16
+ end
17
+
18
+ def version
19
+ "1"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class EntityIndex
6
+ def initialize(storage, extractor: nil)
7
+ @storage = storage
8
+ @extractor = extractor || build_extractor
9
+ end
10
+
11
+ def extract_entities(text)
12
+ @extractor.extract(text)
13
+ end
14
+
15
+ def index_trace(trace)
16
+ entities_touched = 0
17
+ mentions = @extractor.extract(trace.content)
18
+ keys = []
19
+
20
+ mentions.each do |m|
21
+ entity = Entity.build(
22
+ user_id: trace.user_id,
23
+ normalized_key: m[:normalized],
24
+ display_name: m[:text]
25
+ )
26
+ @storage.upsert_entity(entity)
27
+ mention = EntityMention.build(
28
+ user_id: trace.user_id,
29
+ trace_id: trace.id,
30
+ entity_id: entity.id,
31
+ normalized_key: entity.normalized_key,
32
+ text: m[:text],
33
+ offset_start: m[:start],
34
+ offset_end: m[:end]
35
+ )
36
+ @storage.write_mention(mention)
37
+ keys << entity.normalized_key
38
+ entities_touched += 1
39
+ end
40
+
41
+ prev = @storage.previous_trace(trace.user_id, trace.session_id, trace.sequence)
42
+ if prev
43
+ @storage.link_adjacent_traces(trace.user_id, prev.id, trace.id)
44
+ end
45
+
46
+ {
47
+ affected_entities: entities_touched,
48
+ entity_keys: keys.uniq,
49
+ cache_invalidations: entities_touched
50
+ }
51
+ end
52
+
53
+ private
54
+
55
+ def build_extractor
56
+ case Llmemory.configuration.zero_mem_entity_extractor.to_sym
57
+ when :http
58
+ Extractors::Http.new(base_url: Llmemory.configuration.zero_mem_ner_http_url)
59
+ else
60
+ Extractors::Heuristic.new
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class EntityMention
6
+ attr_reader :id, :user_id, :trace_id, :entity_id, :normalized_key, :text, :offset_start, :offset_end
7
+
8
+ def self.build(user_id:, trace_id:, entity_id:, normalized_key:, text:, offset_start: 0, offset_end: nil, id: nil)
9
+ new(
10
+ id: id || "men_#{SecureRandom.hex(8)}",
11
+ user_id: user_id.to_s,
12
+ trace_id: trace_id.to_s,
13
+ entity_id: entity_id.to_s,
14
+ normalized_key: normalized_key.to_s,
15
+ text: text.to_s,
16
+ offset_start: offset_start.to_i,
17
+ offset_end: (offset_end || offset_start.to_i + text.to_s.length).to_i
18
+ )
19
+ end
20
+
21
+ def initialize(id:, user_id:, trace_id:, entity_id:, normalized_key:, text:, offset_start:, offset_end:)
22
+ @id = id
23
+ @user_id = user_id
24
+ @trace_id = trace_id
25
+ @entity_id = entity_id
26
+ @normalized_key = normalized_key
27
+ @text = text
28
+ @offset_start = offset_start
29
+ @offset_end = offset_end
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ require "securerandom"
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ ValidationError = Class.new(Llmemory::Error)
6
+ end
7
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class Evidence
6
+ attr_reader :trace_id, :content, :role, :session_id, :occurred_at, :confidence, :score,
7
+ :sources, :conflict, :seed, :closure, :content_sha256
8
+
9
+ def initialize(trace_id:, content:, role:, session_id:, occurred_at:, confidence:, score:,
10
+ sources: [], conflict: false, seed: true, closure: false, content_sha256: nil)
11
+ @trace_id = trace_id
12
+ @content = content
13
+ @role = role
14
+ @session_id = session_id
15
+ @occurred_at = occurred_at
16
+ @confidence = confidence.to_f
17
+ @score = score.to_f
18
+ @sources = Array(sources).map(&:to_sym).uniq
19
+ @conflict = !!conflict
20
+ @seed = !!seed
21
+ @closure = !!closure
22
+ @content_sha256 = content_sha256
23
+ end
24
+
25
+ def to_h
26
+ {
27
+ trace_id: trace_id,
28
+ content: content,
29
+ role: role,
30
+ session_id: session_id,
31
+ occurred_at: occurred_at.is_a?(Time) ? occurred_at.utc.iso8601(6) : occurred_at,
32
+ confidence: confidence,
33
+ score: score,
34
+ sources: sources,
35
+ conflict: conflict,
36
+ seed: seed,
37
+ closure: closure,
38
+ content_sha256: content_sha256
39
+ }
40
+ end
41
+ end
42
+ end
43
+ end