llmemory 0.2.11 → 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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +23 -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 +59 -1
  9. data/lib/llmemory/instrumentation.rb +10 -1
  10. data/lib/llmemory/maintenance/cognitive_pass.rb +29 -1
  11. data/lib/llmemory/maintenance/zero_mem_ttl.rb +26 -0
  12. data/lib/llmemory/maintenance.rb +1 -0
  13. data/lib/llmemory/mcp/server.rb +2 -0
  14. data/lib/llmemory/mcp/store_helpers.rb +16 -0
  15. data/lib/llmemory/mcp/tools/memory_add_message.rb +17 -4
  16. data/lib/llmemory/mcp/tools/memory_consolidate.rb +9 -4
  17. data/lib/llmemory/mcp/tools/memory_maintain.rb +6 -1
  18. data/lib/llmemory/mcp/tools/memory_retrieve.rb +3 -1
  19. data/lib/llmemory/mcp/tools/memory_retrieve_evidence.rb +70 -0
  20. data/lib/llmemory/mcp/tools/memory_stats.rb +22 -0
  21. data/lib/llmemory/memory.rb +257 -9
  22. data/lib/llmemory/retrieval/bm25_scorer.rb +5 -0
  23. data/lib/llmemory/tokenizer.rb +18 -8
  24. data/lib/llmemory/vector_store/memory_store.rb +23 -1
  25. data/lib/llmemory/vector_store/openai_embeddings.rb +47 -21
  26. data/lib/llmemory/version.rb +1 -1
  27. data/lib/llmemory/zero_mem/answer_calibrator.rb +63 -0
  28. data/lib/llmemory/zero_mem/backfill.rb +43 -0
  29. data/lib/llmemory/zero_mem/budget_assembler.rb +62 -0
  30. data/lib/llmemory/zero_mem/circuit_breaker.rb +37 -0
  31. data/lib/llmemory/zero_mem/embedding_provider.rb +73 -0
  32. data/lib/llmemory/zero_mem/engine.rb +247 -0
  33. data/lib/llmemory/zero_mem/entity.rb +31 -0
  34. data/lib/llmemory/zero_mem/entity_extractor.rb +23 -0
  35. data/lib/llmemory/zero_mem/entity_index.rb +65 -0
  36. data/lib/llmemory/zero_mem/entity_mention.rb +35 -0
  37. data/lib/llmemory/zero_mem/errors.rb +7 -0
  38. data/lib/llmemory/zero_mem/evidence.rb +43 -0
  39. data/lib/llmemory/zero_mem/evidence_calibrator.rb +158 -0
  40. data/lib/llmemory/zero_mem/evidence_closure.rb +98 -0
  41. data/lib/llmemory/zero_mem/evidence_fusion.rb +57 -0
  42. data/lib/llmemory/zero_mem/evidence_set.rb +54 -0
  43. data/lib/llmemory/zero_mem/extractors/heuristic.rb +44 -0
  44. data/lib/llmemory/zero_mem/extractors/http.rb +58 -0
  45. data/lib/llmemory/zero_mem/graph_retriever.rb +107 -0
  46. data/lib/llmemory/zero_mem/hierarchy_builder.rb +123 -0
  47. data/lib/llmemory/zero_mem/hierarchy_retriever.rb +133 -0
  48. data/lib/llmemory/zero_mem/indexer.rb +73 -0
  49. data/lib/llmemory/zero_mem/mode.rb +30 -0
  50. data/lib/llmemory/zero_mem/pagerank.rb +42 -0
  51. data/lib/llmemory/zero_mem/query_profile.rb +48 -0
  52. data/lib/llmemory/zero_mem/query_profiler.rb +138 -0
  53. data/lib/llmemory/zero_mem/repair.rb +31 -0
  54. data/lib/llmemory/zero_mem/router.rb +69 -0
  55. data/lib/llmemory/zero_mem/sidecar_client.rb +82 -0
  56. data/lib/llmemory/zero_mem/storage.rb +124 -0
  57. data/lib/llmemory/zero_mem/storages/active_record.rb +311 -0
  58. data/lib/llmemory/zero_mem/storages/active_record_models.rb +21 -0
  59. data/lib/llmemory/zero_mem/storages/file.rb +67 -0
  60. data/lib/llmemory/zero_mem/storages/memory.rb +294 -0
  61. data/lib/llmemory/zero_mem/storages/postgres.rb +85 -0
  62. data/lib/llmemory/zero_mem/storages/snapshot_codec.rb +180 -0
  63. data/lib/llmemory/zero_mem/storages.rb +43 -0
  64. data/lib/llmemory/zero_mem/trace.rb +100 -0
  65. data/lib/llmemory/zero_mem/trace_state_link.rb +64 -0
  66. data/lib/llmemory/zero_mem/trace_unit.rb +83 -0
  67. data/lib/llmemory/zero_mem.rb +42 -0
  68. data/lib/llmemory.rb +7 -0
  69. data/lib/tasks/llmemory.rake +52 -0
  70. metadata +48 -2
@@ -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
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Llmemory
6
+ module ZeroMem
7
+ class EvidenceCalibrator
8
+ CONFLICT_PATTERNS = [
9
+ /\btalla\s+es\s+([A-Z])\b/i,
10
+ /\bsize\s+is\s+([A-Z])\b/i
11
+ ].freeze
12
+
13
+ def initialize(storage)
14
+ @storage = storage
15
+ end
16
+
17
+ def calibrate(user_id:, traces:, profile:, current_trace_id: nil, as_of: Time.now,
18
+ fusion_rows: nil, seed_ids: nil, closure_ids: nil)
19
+ excluded = []
20
+ kept = []
21
+
22
+ Array(traces).each do |t|
23
+ reason = exclusion_reason(user_id, t, profile, current_trace_id, as_of)
24
+ if reason
25
+ excluded << { id: t.id, reason: reason }
26
+ next
27
+ end
28
+
29
+ kept << t
30
+ end
31
+
32
+ deduped, dup_excluded = dedupe_by_content(kept, fusion_rows)
33
+ excluded.concat(dup_excluded)
34
+
35
+ conflict_ids = detect_conflicts(deduped)
36
+ ordered = sort_traces(deduped, profile, as_of)
37
+
38
+ {
39
+ traces: ordered,
40
+ excluded: excluded,
41
+ conflict_trace_ids: conflict_ids,
42
+ seed_ids: Array(seed_ids).map(&:to_s),
43
+ closure_ids: Array(closure_ids).map(&:to_s)
44
+ }
45
+ end
46
+
47
+ private
48
+
49
+ def exclusion_reason(user_id, trace, profile, current_trace_id, as_of)
50
+ return :wrong_tenant unless trace.user_id == user_id.to_s
51
+ return :archived unless trace.active?
52
+ return :current_query if current_trace_id && trace.id == current_trace_id.to_s
53
+ return :outside_boundary unless boundary_match?(trace, profile.boundary)
54
+ return :hash_mismatch unless sha_ok?(trace)
55
+ return :stale_state unless current_state_ok?(user_id, trace, profile, as_of)
56
+
57
+ nil
58
+ end
59
+
60
+ def boundary_match?(trace, boundary)
61
+ return true unless boundary
62
+
63
+ ok = true
64
+ ok &&= (trace.session_id == boundary[:session_id].to_s) if boundary[:session_id]
65
+ ok &&= (trace.boundary_id == boundary[:boundary_id].to_s) if boundary[:boundary_id]
66
+ ok
67
+ end
68
+
69
+ def sha_ok?(trace)
70
+ Digest::SHA256.hexdigest(trace.content) == trace.content_sha256
71
+ end
72
+
73
+ def current_state_ok?(user_id, trace, profile, as_of)
74
+ return true unless profile.workload_class == :current_state
75
+
76
+ links = @storage.state_links_for(user_id, state_attribute_key(trace))
77
+ return true if links.empty?
78
+
79
+ current_id = @storage.current_state_trace_id(user_id, state_attribute_key(trace), as_of: as_of)
80
+ return true if current_id.nil?
81
+
82
+ trace.id == current_id.to_s || links.any? { |l| l.trace_id == trace.id }
83
+ end
84
+
85
+ def state_attribute_key(trace)
86
+ down = trace.content.downcase
87
+ return "talla" if down.include?("talla")
88
+ return "size" if down.include?("size")
89
+
90
+ "state:#{trace.session_id}"
91
+ end
92
+
93
+ def dedupe_by_content(traces, fusion_rows)
94
+ by_hash = traces.group_by(&:content_sha256)
95
+ kept = []
96
+ excluded = []
97
+ by_hash.each do |hash, group|
98
+ if group.size == 1
99
+ kept << group.first
100
+ next
101
+ end
102
+
103
+ winner = pick_winner(group, fusion_rows)
104
+ kept << winner
105
+ group.each do |t|
106
+ next if t.id == winner.id
107
+
108
+ excluded << { id: t.id, reason: :duplicate_content, content_sha256: hash }
109
+ end
110
+ end
111
+ [kept, excluded]
112
+ end
113
+
114
+ def pick_winner(group, fusion_rows)
115
+ scores = fusion_rows&.index_by { |r| r[:trace_id].to_s } || {}
116
+ group.max_by do |t|
117
+ row = scores[t.id]
118
+ [row ? row[:final].to_f : 0.0, t.occurred_at.to_f]
119
+ end
120
+ end
121
+
122
+ def detect_conflicts(traces)
123
+ buckets = Hash.new { |h, k| h[k] = [] }
124
+ traces.each do |t|
125
+ CONFLICT_PATTERNS.each do |pat|
126
+ m = t.content.match(pat)
127
+ next unless m
128
+
129
+ buckets[pat.source] << { id: t.id, value: m[1].upcase }
130
+ end
131
+ end
132
+
133
+ conflict_ids = []
134
+ buckets.each_value do |entries|
135
+ values = entries.map { |e| e[:value] }.uniq
136
+ next if values.size <= 1
137
+
138
+ conflict_ids.concat(entries.map { |e| e[:id] })
139
+ end
140
+ conflict_ids.uniq
141
+ end
142
+
143
+ def sort_traces(traces, profile, as_of)
144
+ if profile.freshness_requirement || profile.workload_class == :current_state
145
+ traces.sort_by { |t| [-t.occurred_at.to_f, subject_rank(t, profile)] }
146
+ else
147
+ traces.sort_by { |t| [subject_rank(t, profile), -t.occurred_at.to_f] }
148
+ end
149
+ end
150
+
151
+ def subject_rank(trace, profile)
152
+ down = trace.content.downcase
153
+ hits = profile.subject_entities.count { |e| down.include?(e.downcase) }
154
+ -hits
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class EvidenceClosure
6
+ DEFAULT_BRIDGE_BUDGET = 4
7
+ DEFAULT_NEIGHBOR_BUDGET = 4
8
+
9
+ def initialize(storage, config: Llmemory.configuration)
10
+ @storage = storage
11
+ @config = config
12
+ end
13
+
14
+ # Expands fused seeds with relational bridges and local neighbors inside the boundary.
15
+ def expand(user_id:, profile:, seed_trace_ids:, fused_ranking:)
16
+ seed_trace_ids = Array(seed_trace_ids).map(&:to_s).uniq
17
+ return empty(seed_trace_ids) if seed_trace_ids.empty?
18
+
19
+ bridge_budget = [seed_trace_ids.size, DEFAULT_BRIDGE_BUDGET].min
20
+ neighbor_budget = DEFAULT_NEIGHBOR_BUDGET
21
+ per_seed = [neighbor_budget / [seed_trace_ids.size, 1].max, 1].max
22
+
23
+ closure_ids = []
24
+ bridges = []
25
+ groups = []
26
+
27
+ seed_trace_ids.each do |seed_id|
28
+ group = [seed_id]
29
+ added = 0
30
+ prev, nxt = @storage.adjacent_traces(user_id, seed_id)
31
+ [prev, nxt].compact.each do |tid|
32
+ break if added >= per_seed
33
+ next if seed_trace_ids.include?(tid)
34
+ next unless trace_ok?(user_id, tid, profile)
35
+
36
+ closure_ids << tid
37
+ bridges << [seed_id, tid]
38
+ group << tid
39
+ added += 1
40
+ end
41
+ groups << group if group.size > 1
42
+ end
43
+
44
+ span = @config.zero_mem_local_span.to_i
45
+ span = 2 if span <= 0
46
+ seed_trace_ids.each do |seed_id|
47
+ trace = @storage.get_trace(user_id, seed_id)
48
+ next unless trace
49
+
50
+ session_traces = @storage.list_traces(user_id, session_id: trace.session_id)
51
+ by_seq = session_traces.each_with_object({}) { |t, acc| acc[t.sequence] = t.id }
52
+ ((trace.sequence - span)..(trace.sequence + span)).each do |seq|
53
+ tid = by_seq[seq]
54
+ next if tid.nil? || seed_trace_ids.include?(tid) || closure_ids.include?(tid)
55
+ next unless trace_ok?(user_id, tid, profile)
56
+
57
+ closure_ids << tid
58
+ end
59
+ end
60
+
61
+ closure_ids = closure_ids.uniq.first(bridge_budget + neighbor_budget)
62
+ {
63
+ seed_trace_ids: seed_trace_ids,
64
+ closure_trace_ids: closure_ids,
65
+ bridges: bridges.first(bridge_budget),
66
+ atomic_groups: groups.map { |g| g.uniq.sort }
67
+ }
68
+ end
69
+
70
+ private
71
+
72
+ def empty(seed_trace_ids)
73
+ {
74
+ seed_trace_ids: seed_trace_ids,
75
+ closure_trace_ids: [],
76
+ bridges: [],
77
+ atomic_groups: []
78
+ }
79
+ end
80
+
81
+ def trace_ok?(user_id, trace_id, profile)
82
+ trace = @storage.get_trace(user_id, trace_id)
83
+ return false unless trace&.active?
84
+
85
+ boundary_match?(trace, profile.boundary)
86
+ end
87
+
88
+ def boundary_match?(trace, boundary)
89
+ return true unless boundary
90
+
91
+ ok = true
92
+ ok &&= (trace.session_id == boundary[:session_id].to_s) if boundary[:session_id]
93
+ ok &&= (trace.boundary_id == boundary[:boundary_id].to_s) if boundary[:boundary_id]
94
+ ok
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class EvidenceFusion
6
+ # When all raw scores are equal, min-max yields this normalized value (see benchmarks/zero_mem/profile.yml).
7
+ EQUAL_SCORE_NORMALIZED = 0.5
8
+
9
+ def fuse(graph_scores:, hierarchy_scores:, weights:)
10
+ g_norm = min_max(graph_scores)
11
+ h_norm = min_max(hierarchy_scores)
12
+ ids = (g_norm.keys + h_norm.keys).uniq
13
+
14
+ fused = ids.map do |trace_id|
15
+ graph = g_norm.fetch(trace_id, 0.0)
16
+ hierarchy = h_norm.fetch(trace_id, 0.0)
17
+ graph_w = weights.fetch(:graph, 0.5).to_f
18
+ hierarchy_w = weights.fetch(:hierarchy, 0.5).to_f
19
+ total = graph_w + hierarchy_w
20
+ graph_w /= total
21
+ hierarchy_w /= total
22
+ final = (graph_w * graph) + (hierarchy_w * hierarchy)
23
+ sources = []
24
+ sources << :graph if graph_scores.key?(trace_id)
25
+ sources << :hierarchy if hierarchy_scores.key?(trace_id)
26
+
27
+ {
28
+ trace_id: trace_id,
29
+ graph: graph,
30
+ hierarchy: hierarchy,
31
+ graph_raw: graph_scores[trace_id],
32
+ hierarchy_raw: hierarchy_scores[trace_id],
33
+ final: final,
34
+ sources: sources
35
+ }
36
+ end
37
+
38
+ fused.sort_by { |row| [-row[:final], row[:trace_id]] }
39
+ end
40
+
41
+ def min_max(scores)
42
+ return {} if scores.nil? || scores.empty?
43
+
44
+ values = scores.values.map(&:to_f)
45
+ min = values.min
46
+ max = values.max
47
+ if max == min
48
+ return scores.keys.to_h { |id| [id, EQUAL_SCORE_NORMALIZED] }
49
+ end
50
+
51
+ scores.transform_values do |v|
52
+ ((v.to_f - min) / (max - min)).clamp(0.0, 1.0)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class EvidenceSet
6
+ attr_reader :route, :profile, :evidence, :metrics, :explain, :degraded,
7
+ :relational_trace_scores, :hierarchy_traces, :relational_traces, :warnings,
8
+ :seed_evidence, :closure_evidence
9
+
10
+ def initialize(route:, profile:, evidence:, metrics:, explain: nil, degraded: [],
11
+ relational_trace_scores: {}, hierarchy_traces: [], relational_traces: [],
12
+ warnings: [], seed_evidence: [], closure_evidence: [])
13
+ @route = route
14
+ @profile = profile
15
+ @evidence = evidence
16
+ @metrics = metrics
17
+ @explain = explain
18
+ @degraded = Array(degraded)
19
+ @relational_trace_scores = relational_trace_scores
20
+ @hierarchy_traces = hierarchy_traces
21
+ @relational_traces = relational_traces
22
+ @warnings = Array(warnings)
23
+ @seed_evidence = Array(seed_evidence)
24
+ @closure_evidence = Array(closure_evidence)
25
+ end
26
+
27
+ def recall_at_k(gold_trace_ids, k: nil)
28
+ k ||= evidence.size
29
+ gold = Array(gold_trace_ids).map(&:to_s)
30
+ ranked = evidence.map(&:trace_id).first(k)
31
+ hit = (gold & ranked).size
32
+ {
33
+ hit: hit,
34
+ gold: gold.size,
35
+ recall: gold.empty? ? 0.0 : hit.to_f / gold.size,
36
+ ranked: ranked
37
+ }
38
+ end
39
+
40
+ def to_context
41
+ return "" if evidence.empty?
42
+
43
+ lines = ["=== ZERO-MEM EVIDENCE ===", ""]
44
+ evidence.each do |ev|
45
+ ts = ev.occurred_at.is_a?(Time) ? ev.occurred_at.utc.iso8601 : ev.occurred_at.to_s
46
+ lines << "[#{ev.trace_id}] #{ts} #{ev.session_id} #{ev.role}: #{ev.content}"
47
+ end
48
+ lines << ""
49
+ lines << "=== END ZERO-MEM EVIDENCE ==="
50
+ lines.join("\n")
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../entity_extractor"
4
+
5
+ module Llmemory
6
+ module ZeroMem
7
+ module Extractors
8
+ class Heuristic < EntityExtractor
9
+ CAP_PHRASE = /\b[\p{Lu}][\p{Ll}]+(?:\s+[\p{Lu}][\p{Ll}]+)*\b/u
10
+ ALL_CAPS = /\b[\p{Lu}]{2,}\b/u
11
+
12
+ def extract(text)
13
+ normalized_text = Llmemory::Tokenizer.normalize(text)
14
+ spans = []
15
+ text.to_s.scan(CAP_PHRASE) { |m| spans << [Regexp.last_match.begin(0), m] }
16
+ text.to_s.scan(ALL_CAPS) { |m| spans << [Regexp.last_match.begin(0), m] }
17
+
18
+ spans.map do |start, raw|
19
+ norm = normalize_key(raw)
20
+ next if norm.length < 2
21
+
22
+ {
23
+ text: raw,
24
+ normalized: norm,
25
+ type: :entity,
26
+ start: start,
27
+ end: start + raw.length
28
+ }
29
+ end.compact.uniq { |h| h[:normalized] }
30
+ end
31
+
32
+ def name
33
+ "heuristic"
34
+ end
35
+
36
+ private
37
+
38
+ def normalize_key(raw)
39
+ Llmemory::Tokenizer.normalize(raw).gsub(/\s+/, " ").strip
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "faraday"
5
+ require_relative "../entity_extractor"
6
+
7
+ module Llmemory
8
+ module ZeroMem
9
+ module Extractors
10
+ class Http < EntityExtractor
11
+ def initialize(base_url:, timeout: 5, client: nil)
12
+ @base_url = base_url.to_s.sub(%r{/\z}, "")
13
+ @timeout = timeout
14
+ @client = client
15
+ end
16
+
17
+ def extract(text)
18
+ body = sidecar.post_json("/ner", { text: text.to_s })
19
+ Array(body["entities"]).map do |ent|
20
+ {
21
+ text: ent["text"],
22
+ normalized: ent["normalized"] || ent["text"].to_s.downcase,
23
+ type: (ent["type"] || "entity").to_sym,
24
+ start: ent["start"] || 0,
25
+ end: ent["end"] || 0
26
+ }
27
+ end
28
+ end
29
+
30
+ def name
31
+ "http"
32
+ end
33
+
34
+ def version
35
+ health["version"] || "unknown"
36
+ rescue StandardError
37
+ "unknown"
38
+ end
39
+
40
+ def health
41
+ sidecar.get_json("/health")
42
+ rescue StoreError
43
+ { "status" => "down" }
44
+ end
45
+
46
+ def degraded?
47
+ sidecar.degraded?
48
+ end
49
+
50
+ private
51
+
52
+ def sidecar
53
+ @client ||= SidecarClient.new(base_url: @base_url, timeout: @timeout)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end