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,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../retrieval/bm25_scorer"
4
+
5
+ module Llmemory
6
+ module ZeroMem
7
+ class HierarchyRetriever
8
+ def initialize(storage, config: Llmemory.configuration)
9
+ @storage = storage
10
+ @config = config
11
+ @bm25 = Retrieval::Bm25Scorer.new
12
+ end
13
+
14
+ def retrieve(user_id:, query:, profile:, top_k:)
15
+ k = top_k || @config.zero_mem_top_k
16
+ episodes = filtered_episodes(user_id, profile)
17
+ ranked_episodes = rank_units(query, profile, episodes)
18
+ top_episodes = ranked_episodes.first([k, ranked_episodes.size].min)
19
+
20
+ windows = top_episodes.flat_map do |ep|
21
+ @storage.list_units(user_id, kind: :window, session_id: ep.session_id).select do |w|
22
+ (w.member_trace_ids - ep.member_trace_ids).empty? || (w.member_trace_ids & ep.member_trace_ids).any?
23
+ end
24
+ end
25
+ windows = @storage.list_units(user_id, kind: :window) if windows.empty?
26
+ ranked_windows = rank_units(query, profile, windows.uniq { |u| u.id })
27
+ top_windows = ranked_windows.first([k, ranked_windows.size].min)
28
+
29
+ turns = top_windows.flat_map do |win|
30
+ @storage.list_units(user_id, kind: :turn, session_id: win.session_id).select do |t|
31
+ t.start_sequence >= win.start_sequence && t.end_sequence <= win.end_sequence
32
+ end
33
+ end
34
+ turns = @storage.list_units(user_id, kind: :turn) if turns.empty?
35
+ ranked_turns = rank_units(query, profile, turns.uniq { |u| u.id })
36
+ top_turns = ranked_turns.first([k, ranked_turns.size].min)
37
+
38
+ trace_ids = top_turns.flat_map(&:member_trace_ids)
39
+ trace_ids = expand_local_neighbors(user_id, trace_ids, top_turns)
40
+ traces = trace_ids.map { |id| @storage.get_trace(user_id, id) }.compact
41
+
42
+ {
43
+ traces: traces,
44
+ degraded: [:dense],
45
+ stage_scores: {
46
+ episodes: ranked_episodes.first(3).map { |u| score_unit(query, profile, u) },
47
+ windows: ranked_windows.first(3).map { |u| score_unit(query, profile, u) },
48
+ turns: ranked_turns.first(3).map { |u| score_unit(query, profile, u) }
49
+ }
50
+ }
51
+ end
52
+
53
+ private
54
+
55
+ def filtered_episodes(user_id, profile)
56
+ units = @storage.list_units(user_id, kind: :episode)
57
+ boundary = profile.boundary
58
+ return units unless boundary
59
+
60
+ units.select do |u|
61
+ ok = true
62
+ ok &&= (u.session_id == boundary[:session_id]) if boundary[:session_id]
63
+ ok &&= (u.boundary_id == boundary[:boundary_id]) if boundary[:boundary_id]
64
+ ok
65
+ end
66
+ end
67
+
68
+ def rank_units(query, profile, units)
69
+ docs = units.map do |u|
70
+ { id: u.id, text: unit_text(u), unit: u }
71
+ end
72
+ scored = @bm25.score_documents(query, docs)
73
+ scored.sort_by { |d| -combined_score(query, profile, d) }
74
+ .map { |d| d[:unit] }
75
+ end
76
+
77
+ def combined_score(query, profile, doc)
78
+ unit = doc[:unit]
79
+ bm25 = doc[:normalized_bm25].to_f
80
+ phrase = phrase_boost(profile, doc[:text])
81
+ subject = subject_boost(profile, doc[:text])
82
+ temporal = temporal_boost(profile, unit)
83
+ boundary = profile.boundary ? 0.1 : 0.0
84
+ answer = 0.05
85
+ bm25 + phrase + subject + temporal + boundary + answer
86
+ end
87
+
88
+ def score_unit(query, profile, unit)
89
+ doc = @bm25.score_documents(query, [{ id: unit.id, text: unit_text(unit), unit: unit }]).first
90
+ combined_score(query, profile, doc)
91
+ end
92
+
93
+ def unit_text(unit)
94
+ unit.member_trace_ids.map do |tid|
95
+ @storage.get_trace(unit.user_id, tid)&.content
96
+ end.compact.join(" ")
97
+ end
98
+
99
+ def phrase_boost(profile, text)
100
+ down = text.downcase
101
+ profile.quoted_phrases.count { |p| down.include?(p.downcase) } * 0.2
102
+ end
103
+
104
+ def subject_boost(profile, text)
105
+ down = text.downcase
106
+ profile.subject_entities.count { |e| down.include?(e.downcase) } * 0.15
107
+ end
108
+
109
+ def temporal_boost(profile, unit)
110
+ return 0.0 unless profile.freshness_requirement
111
+
112
+ age = Time.now - unit.occurred_to
113
+ 1.0 / (1.0 + (age / 86_400.0))
114
+ end
115
+
116
+ def expand_local_neighbors(user_id, trace_ids, turn_units)
117
+ span = @config.zero_mem_local_span.to_i
118
+ span = 2 if span <= 0
119
+ sessions = turn_units.map(&:session_id).uniq
120
+ all_traces = sessions.flat_map { |sid| @storage.list_traces(user_id, session_id: sid) }
121
+ by_seq = all_traces.each_with_object({}) { |t, acc| acc[t.sequence] = t }
122
+ expanded = trace_ids.dup
123
+ turn_units.each do |tu|
124
+ seq = tu.start_sequence
125
+ ((seq - span)..(seq + span)).each do |s|
126
+ expanded << by_seq[s].id if by_seq[s]
127
+ end
128
+ end
129
+ expanded.uniq
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ # Index watermark + hierarchical units + entity graph (ZM2/ZM3).
6
+ class Indexer
7
+ def initialize(storage, mode: nil, builder: nil, entity_index: nil, embedding_provider: nil)
8
+ @storage = storage
9
+ @mode = (mode || Llmemory.configuration.zero_mem_index_mode).to_sym
10
+ @builder = builder || HierarchyBuilder.new(storage)
11
+ @entity_index = entity_index || EntityIndex.new(storage)
12
+ @embedding_provider = embedding_provider
13
+ end
14
+
15
+ def after_trace_write(trace:, state_link: false)
16
+ wm = @storage.get_watermark(trace.user_id, trace.session_id)
17
+ next_version = wm[:index_version].to_i + 1
18
+ affected_units = @builder.index_trace(trace, index_version: next_version)
19
+ entity_stats = @entity_index.index_trace(trace)
20
+ encoder_stats = index_embedding(trace)
21
+
22
+ @storage.set_watermark(
23
+ trace.user_id,
24
+ trace.session_id,
25
+ last_sequence: trace.sequence,
26
+ index_version: next_version
27
+ )
28
+
29
+ {
30
+ index_mode: @mode,
31
+ index_lag: @mode == :deferred,
32
+ affected_units: affected_units,
33
+ affected_entities: entity_stats[:affected_entities],
34
+ cache_invalidations: entity_stats[:cache_invalidations],
35
+ maintenance_fanout: (state_link ? 2 : 1) + affected_units + entity_stats[:affected_entities],
36
+ encoder_calls: encoder_stats[:encoder_calls],
37
+ encoder_duration_ms: encoder_stats[:encoder_duration_ms]
38
+ }
39
+ end
40
+
41
+ private
42
+
43
+ def index_embedding(trace)
44
+ provider = @embedding_provider || configured_embedding_provider
45
+ return { encoder_calls: 0, encoder_duration_ms: 0.0 } unless provider
46
+
47
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
+ vector = provider.embed_one(trace.content)
49
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
50
+ @storage.store_turn_embedding(
51
+ trace.user_id,
52
+ trace.id,
53
+ vector,
54
+ model: provider.model,
55
+ dimensions: provider.dimensions
56
+ )
57
+ Llmemory::Instrumentation.instrument(
58
+ :encoder_embed,
59
+ user_id: trace.user_id,
60
+ trace_id: trace.id,
61
+ model: provider.model,
62
+ dimensions: provider.dimensions,
63
+ duration_ms: elapsed
64
+ )
65
+ { encoder_calls: 1, encoder_duration_ms: elapsed }
66
+ end
67
+
68
+ def configured_embedding_provider
69
+ Llmemory.configuration.zero_mem_embedding_provider
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ module Mode
6
+ MODES = %i[classic zero_mem hybrid].freeze
7
+
8
+ module_function
9
+
10
+ def valid?(mode)
11
+ MODES.include?(mode.to_sym)
12
+ end
13
+
14
+ def normalize(mode)
15
+ sym = (mode || :classic).to_sym
16
+ raise Llmemory::ConfigurationError, "invalid memory_mode: #{mode.inspect}" unless valid?(sym)
17
+
18
+ sym
19
+ end
20
+
21
+ def zero_mem_enabled?(mode)
22
+ %i[zero_mem hybrid].include?(normalize(mode))
23
+ end
24
+
25
+ def zero_mem_strict?(mode)
26
+ normalize(mode) == :zero_mem
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class PageRank
6
+ def initialize(gamma: 0.6, max_iterations: 30, tolerance: 1e-6)
7
+ @gamma = gamma.to_f
8
+ @max_iterations = max_iterations
9
+ @tolerance = tolerance
10
+ end
11
+
12
+ # graph: { node_id => [neighbor_ids] }, reset: { node_id => weight }
13
+ def run(graph, reset)
14
+ nodes = (graph.keys + reset.keys).uniq
15
+ return {} if nodes.empty?
16
+
17
+ out_degree = graph.transform_values { |nbs| [nbs.size, 1].max }
18
+ rank = nodes.to_h { |n| [n, 1.0 / nodes.size] }
19
+ reset_total = reset.values.sum
20
+ reset = reset.transform_values { |w| reset_total.zero? ? 0.0 : w / reset_total }
21
+
22
+ @max_iterations.times do
23
+ next_rank = nodes.to_h { |n| [n, 0.0] }
24
+ graph.each do |node, neighbors|
25
+ share = rank[node] / out_degree[node]
26
+ neighbors.each do |nb|
27
+ next_rank[nb] ||= 0.0
28
+ next_rank[nb] += @gamma * share
29
+ end
30
+ end
31
+ nodes.each do |n|
32
+ next_rank[n] += (1.0 - @gamma) * reset.fetch(n, 0.0)
33
+ end
34
+ delta = nodes.sum { |n| (next_rank[n] - rank[n]).abs }
35
+ rank = next_rank
36
+ break if delta < @tolerance
37
+ end
38
+ rank
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class QueryProfile
6
+ ANSWER_TYPES = %i[person place datetime number duration boolean list free_text].freeze
7
+ WORKLOADS = %i[local_fact cross_session temporal current_state multi_hop procedural].freeze
8
+
9
+ attr_reader :subject_entities, :keywords, :quoted_phrases, :answer_type, :temporal_cues,
10
+ :aggregation_cues, :boundary, :workload_class, :freshness_requirement,
11
+ :expected_evidence_count, :language, :rule_ids
12
+
13
+ def initialize(subject_entities:, keywords:, quoted_phrases:, answer_type:, temporal_cues:,
14
+ aggregation_cues:, boundary:, workload_class:, freshness_requirement:,
15
+ expected_evidence_count:, language:, rule_ids:)
16
+ @subject_entities = subject_entities
17
+ @keywords = keywords
18
+ @quoted_phrases = quoted_phrases
19
+ @answer_type = answer_type
20
+ @temporal_cues = temporal_cues
21
+ @aggregation_cues = aggregation_cues
22
+ @boundary = boundary
23
+ @workload_class = workload_class
24
+ @freshness_requirement = freshness_requirement
25
+ @expected_evidence_count = expected_evidence_count
26
+ @language = language
27
+ @rule_ids = rule_ids
28
+ end
29
+
30
+ def to_h
31
+ {
32
+ subject_entities: subject_entities,
33
+ keywords: keywords,
34
+ quoted_phrases: quoted_phrases,
35
+ answer_type: answer_type,
36
+ temporal_cues: temporal_cues,
37
+ aggregation_cues: aggregation_cues,
38
+ boundary: boundary,
39
+ workload_class: workload_class,
40
+ freshness_requirement: freshness_requirement,
41
+ expected_evidence_count: expected_evidence_count,
42
+ language: language,
43
+ rule_ids: rule_ids
44
+ }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class QueryProfiler
6
+ TEMPORAL_EN = /\b(before|after|first|last|when|timeline|january|february|march|april|may|june|july|august|september|october|november|december|booking|remind)\b/i
7
+ TEMPORAL_ES = /\b(antes|después|primero|último|ultimo|cuándo|cuando|hora|enero|febrero|marzo|abril|mayo|junio|julio|agosto|septiembre|octubre|noviembre|diciembre|reserva|recuerda)\b/i
8
+ CURRENT_EN = /\b(now|today|current|currently|what is)\b/i
9
+ CURRENT_ES = /\b(ahora|actual|hoy|cuál es|cual es)\b/i
10
+ MULTI_EN = /\b(between|compared to|relationship|and .+ and)\b/i
11
+ MULTI_ES = /\b(relación|relacion|entre|comparado)\b/i
12
+ PROCEDURAL = /\b(export|file|balance|informe|report|saldo)\b/i
13
+
14
+ def profile(query, boundary: nil, language: nil)
15
+ text = query.to_s.strip
16
+ lang = language || detect_language(text)
17
+ rule_ids = []
18
+ quoted = text.scan(/"([^"]+)"/).flatten
19
+ keywords = Llmemory::Tokenizer.tokenize(text)
20
+ entities = extract_entities(text, lang)
21
+
22
+ temporal = temporal_cues(text, lang)
23
+ rule_ids << "temporal_cues" unless temporal.empty?
24
+
25
+ workload = infer_workload(text, lang, entities, temporal, rule_ids)
26
+ answer_type = infer_answer_type(text, lang, rule_ids)
27
+ freshness = workload == :current_state || workload == :temporal
28
+
29
+ boundary_h = normalize_boundary(boundary)
30
+ rule_ids << "boundary_explicit" if boundary_h
31
+
32
+ QueryProfile.new(
33
+ subject_entities: entities,
34
+ keywords: keywords,
35
+ quoted_phrases: quoted,
36
+ answer_type: answer_type,
37
+ temporal_cues: temporal,
38
+ aggregation_cues: aggregation_cues(text),
39
+ boundary: boundary_h,
40
+ workload_class: workload,
41
+ freshness_requirement: freshness,
42
+ expected_evidence_count: expected_count(workload, aggregation_cues(text)),
43
+ language: lang,
44
+ rule_ids: rule_ids.uniq
45
+ )
46
+ end
47
+
48
+ private
49
+
50
+ def detect_language(text)
51
+ text.match?(/[¿¡áéíóúñÁÉÍÓÚÑ]/) || text.match?(TEMPORAL_ES) ? :es : :en
52
+ end
53
+
54
+ def extract_entities(text, _lang)
55
+ text.scan(/\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*\b/).uniq.first(5)
56
+ end
57
+
58
+ def temporal_cues(text, lang)
59
+ cues = []
60
+ cues.concat(text.scan(lang == :es ? TEMPORAL_ES : TEMPORAL_EN).flatten)
61
+ cues.concat(text.scan(/\b\d{1,2}:\d{2}\b/))
62
+ cues.concat(text.scan(/\b\d{4}-\d{2}-\d{2}\b/))
63
+ cues.map(&:to_s).uniq
64
+ end
65
+
66
+ def aggregation_cues(text)
67
+ text.scan(/\b(all|every|list|cuántos|cuantos|how many)\b/i).flatten.uniq
68
+ end
69
+
70
+ def infer_workload(text, lang, entities, temporal, rule_ids)
71
+ if text.match?(PROCEDURAL)
72
+ rule_ids << "procedural_cue"
73
+ return :procedural
74
+ end
75
+ if entities.size >= 2 || text.match?(lang == :es ? MULTI_ES : MULTI_EN)
76
+ rule_ids << "multi_entity"
77
+ return :multi_hop
78
+ end
79
+ if text.match?(lang == :es ? CURRENT_ES : CURRENT_EN)
80
+ rule_ids << "current_state_cue"
81
+ return :current_state
82
+ end
83
+ if temporal.any?
84
+ rule_ids << "temporal_workload"
85
+ return :temporal
86
+ end
87
+ if text.match?(/\b(session|sesión|sesion|conversation|earlier)\b/i)
88
+ rule_ids << "cross_session_cue"
89
+ return :cross_session
90
+ end
91
+ :local_fact
92
+ end
93
+
94
+ def infer_answer_type(text, lang, rule_ids)
95
+ down = text.downcase
96
+ if down.match?(/\b(when|hora|time|fecha|date)\b/)
97
+ rule_ids << "answer_datetime"
98
+ return :datetime
99
+ end
100
+ if down.match?(/\b(how many|cuánt|cuant|number|número|numero)\b/)
101
+ rule_ids << "answer_number"
102
+ return :number
103
+ end
104
+ if down.match?(/\b(where|dónde|donde|place|address|dirección|direccion)\b/)
105
+ rule_ids << "answer_place"
106
+ return :place
107
+ end
108
+ if down.match?(/\b(yes|no|true|false|sí|si)\b/)
109
+ rule_ids << "answer_boolean"
110
+ return :boolean
111
+ end
112
+ if down.match?(/\b(list|which files|qué archivos|que archivos)\b/)
113
+ rule_ids << "answer_list"
114
+ return :list
115
+ end
116
+ :free_text
117
+ end
118
+
119
+ def expected_count(workload, aggregation_cues = [])
120
+ return [3, Llmemory.configuration.zero_mem_max_top_k].min if aggregation_cues.any?
121
+
122
+ case workload
123
+ when :multi_hop then 3
124
+ when :procedural then 2
125
+ when :temporal then 2
126
+ else 1
127
+ end
128
+ end
129
+
130
+ def normalize_boundary(boundary)
131
+ return nil if boundary.nil?
132
+
133
+ b = boundary.is_a?(Hash) ? boundary.transform_keys(&:to_sym) : { session_id: boundary }
134
+ b.compact
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class Repair
6
+ def initialize(storage:)
7
+ @storage = storage
8
+ end
9
+
10
+ # Reconcile watermark with persisted traces and checkpoint message counts (ZM1).
11
+ def run!(user_id:, session_id:, checkpoint_messages:)
12
+ traces = @storage.list_traces(user_id, session_id: session_id)
13
+ last_sequence = traces.map(&:sequence).max.to_i
14
+ wm = @storage.get_watermark(user_id, session_id)
15
+ @storage.set_watermark(
16
+ user_id,
17
+ session_id,
18
+ last_sequence: last_sequence,
19
+ index_version: [wm[:index_version].to_i, last_sequence].max
20
+ )
21
+
22
+ {
23
+ trace_count: traces.size,
24
+ checkpoint_message_count: Array(checkpoint_messages).size,
25
+ last_sequence: last_sequence,
26
+ index_version: [wm[:index_version].to_i, last_sequence].max
27
+ }
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class Router
6
+ RELATIONAL_WORKLOADS = %i[multi_hop].freeze
7
+ LOCAL_WORKLOADS = %i[temporal procedural cross_session local_fact].freeze
8
+
9
+ def route(profile, hierarchy_scores:, graph_scores:, config: Llmemory.configuration)
10
+ weights, route, tie_break = route_for_workload(profile, config)
11
+ if tie_break
12
+ weights = resolve_tie(weights, hierarchy_scores, graph_scores, profile)
13
+ end
14
+
15
+ {
16
+ route: route,
17
+ weights: weights,
18
+ rho: weights[:graph],
19
+ tie_break: tie_break
20
+ }
21
+ end
22
+
23
+ private
24
+
25
+ def route_for_workload(profile, config)
26
+ workload = profile.workload_class
27
+ if RELATIONAL_WORKLOADS.include?(workload) || profile.aggregation_cues.any?
28
+ return [relational_weights(config), :relational, false]
29
+ end
30
+ if LOCAL_WORKLOADS.include?(workload)
31
+ return [local_weights(config), :local, false]
32
+ end
33
+ if workload == :current_state
34
+ base = profile.subject_entities.size >= 2 ? relational_weights(config) : local_weights(config)
35
+ return [base, :current_state, profile.subject_entities.size < 2]
36
+ end
37
+
38
+ [local_weights(config), :local, true]
39
+ end
40
+
41
+ def relational_weights(config)
42
+ rho = config.zero_mem_graph_weight || 0.6
43
+ { graph: rho.to_f, hierarchy: 1.0 - rho.to_f }
44
+ end
45
+
46
+ def local_weights(_config)
47
+ { graph: 0.4, hierarchy: 0.6 }
48
+ end
49
+
50
+ def resolve_tie(default_weights, hierarchy_scores, graph_scores, profile)
51
+ h = anchor_confidence(hierarchy_scores, profile)
52
+ g = anchor_confidence(graph_scores, profile)
53
+ if g > h
54
+ relational_weights(Llmemory.configuration)
55
+ elsif h > g
56
+ local_weights(Llmemory.configuration)
57
+ else
58
+ default_weights
59
+ end
60
+ end
61
+
62
+ def anchor_confidence(scores, profile)
63
+ return 0.0 if scores.nil? || scores.empty?
64
+
65
+ scores.values.sum.to_f / scores.size
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+ require "faraday"
6
+
7
+ module Llmemory
8
+ module ZeroMem
9
+ class SidecarClient
10
+ MAX_TEXT_BYTES = 32_768
11
+
12
+ def initialize(base_url:, timeout: 5, breaker: nil)
13
+ @base_url = base_url.to_s.sub(%r{/\z}, "")
14
+ @timeout = timeout.to_f
15
+ @breaker = breaker || CircuitBreaker.new
16
+ validate_host!
17
+ end
18
+
19
+ def get_json(path)
20
+ raise StoreError, "sidecar circuit open" unless @breaker.allow_request?
21
+
22
+ response = connection.get(path)
23
+ unless response.success?
24
+ @breaker.record_failure
25
+ raise StoreError, "sidecar HTTP error: #{response.status}"
26
+ end
27
+ @breaker.record_success
28
+ JSON.parse(response.body)
29
+ rescue Faraday::Error
30
+ @breaker.record_failure
31
+ raise StoreError, "sidecar request failed"
32
+ end
33
+
34
+ def post_json(path, body)
35
+ raise StoreError, "sidecar circuit open" unless @breaker.allow_request?
36
+
37
+ text = body[:text].to_s
38
+ if text.bytesize > MAX_TEXT_BYTES
39
+ raise StoreError, "sidecar payload exceeds max text bytes (#{MAX_TEXT_BYTES})"
40
+ end
41
+
42
+ response = connection.post(path) do |req|
43
+ req.headers["Content-Type"] = "application/json"
44
+ req.body = body.to_json
45
+ end
46
+ unless response.success?
47
+ @breaker.record_failure
48
+ raise StoreError, "sidecar HTTP error: #{response.status}"
49
+ end
50
+
51
+ @breaker.record_success
52
+ JSON.parse(response.body)
53
+ rescue Faraday::Error
54
+ @breaker.record_failure
55
+ raise StoreError, "sidecar request failed"
56
+ end
57
+
58
+ def degraded?
59
+ @breaker.open?
60
+ end
61
+
62
+ private
63
+
64
+ def connection
65
+ @connection ||= Faraday.new(url: @base_url, request: { timeout: @timeout, open_timeout: @timeout }) do |f|
66
+ f.adapter Faraday.default_adapter
67
+ end
68
+ end
69
+
70
+ def validate_host!
71
+ return unless Llmemory.configuration.zero_mem_require_local_encoders
72
+
73
+ uri = URI.parse(@base_url)
74
+ host = uri.host.to_s
75
+ allowed = %w[127.0.0.1 localhost ::1]
76
+ return if allowed.include?(host)
77
+
78
+ raise ConfigurationError, "zero_mem sidecar host must be loopback when require_local_encoders is enabled"
79
+ end
80
+ end
81
+ end
82
+ end