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
@@ -10,9 +10,20 @@ module Llmemory
10
10
  DEFAULT_SESSION_ID = "default"
11
11
  STATE_KEY_MESSAGES = :messages
12
12
 
13
- def initialize(user_id:, session_id: DEFAULT_SESSION_ID, checkpoint: nil, long_term: nil, long_term_type: nil, retrieval_engine: nil, working_memory: nil, episodic: nil, procedural: nil, api_key: nil, encryption_key: :inherit)
13
+ def initialize(user_id:, session_id: DEFAULT_SESSION_ID, checkpoint: nil, long_term: nil, long_term_type: nil,
14
+ retrieval_engine: nil, working_memory: nil, episodic: nil, procedural: nil, api_key: nil,
15
+ encryption_key: :inherit, trace_store: nil, compact_strategy: nil, forget_log: nil,
16
+ memory_mode: nil)
14
17
  @user_id = user_id
15
18
  @session_id = session_id
19
+ @memory_mode = ZeroMem::Mode.normalize(memory_mode || Llmemory.configuration.memory_mode)
20
+ @trace_store = trace_store
21
+ if @trace_store.nil? && (zero_mem_enabled? || shadow_write_enabled?)
22
+ @trace_store = ZeroMem::Storages.build
23
+ end
24
+ @compact_strategy = compact_strategy
25
+ @forget_log = forget_log
26
+ @trace_indexer = @trace_store ? ZeroMem::Indexer.new(@trace_store) : nil
16
27
  resolved_key = encryption_key == :inherit ? nil : encryption_key
17
28
  @cipher = Llmemory.build_cipher(resolved_key)
18
29
  if checkpoint
@@ -74,6 +85,7 @@ module Llmemory
74
85
  # Reflects over recent episodes and writes distilled insights to the
75
86
  # semantic store (file/graph) with provenance back to source episodes.
76
87
  def reflect!(window: 10, category: "insights")
88
+ deny_generative!(:reflect!) if zero_mem_strict?
77
89
  Reflection::Reflector.new(episodic: episodic, semantic: @long_term, llm: tracked_llm_client)
78
90
  .reflect(window: window, category: category)
79
91
  end
@@ -89,6 +101,7 @@ module Llmemory
89
101
  # `auto_register: true`, registers them in procedural memory (with provenance
90
102
  # back to the source episodes) and returns the new skill ids.
91
103
  def mine_skills!(window: SkillMining::Miner::DEFAULT_WINDOW, outcomes: nil, auto_register: false)
104
+ deny_generative!(:mine_skills!) if zero_mem_strict?
92
105
  SkillMining::Miner.new(episodic: episodic, procedural: procedural, llm: tracked_llm_client)
93
106
  .mine(window: window, outcomes: outcomes, auto_register: auto_register)
94
107
  end
@@ -104,18 +117,135 @@ module Llmemory
104
117
  )
105
118
  end
106
119
 
107
- def add_message(role:, content:)
108
- @short_term_store.update(@user_id, @session_id) do |state|
109
- state = normalize_state_hash(state)
110
- list = state[STATE_KEY_MESSAGES]
111
- list = list.is_a?(Array) ? list.dup : []
112
- list << { role: role.to_sym, content: content.to_s }
113
- list = sanitize_messages(list) if Llmemory.configuration.message_sanitizer_enabled
114
- state.merge(STATE_KEY_MESSAGES => list, last_activity_at: Time.now, **preserved_flush_state_from(state))
120
+ def add_message(role:, content:, occurred_at: nil, boundary_id: nil, metadata: nil, idempotency_key: nil)
121
+ if @trace_store
122
+ record_trace(
123
+ role: role,
124
+ content: content,
125
+ occurred_at: occurred_at,
126
+ boundary_id: boundary_id,
127
+ metadata: metadata,
128
+ idempotency_key: idempotency_key,
129
+ add_to_checkpoint: true
130
+ )
131
+ else
132
+ append_message_to_checkpoint(role: role, content: content)
133
+ end
134
+ true
135
+ end
136
+
137
+ # Persists an immutable trace before updating the checkpoint (Zero-Mem source of truth).
138
+ def record_trace(role:, content:, occurred_at: nil, boundary_id: nil, metadata: nil, idempotency_key: nil,
139
+ state_key: nil, valid_from: nil, valid_to: nil, supersedes_trace_id: nil, source: :explicit,
140
+ add_to_checkpoint: true)
141
+ raise ConfigurationError, "trace_store is not configured" unless @trace_store
142
+
143
+ if idempotency_key && (existing = @trace_store.find_by_idempotency_key(@user_id, idempotency_key))
144
+ return existing.id
145
+ end
146
+
147
+ sequence = @trace_store.next_sequence(@user_id, @session_id)
148
+ trace = ZeroMem::Trace.build(
149
+ user_id: @user_id,
150
+ session_id: @session_id,
151
+ role: role,
152
+ content: content,
153
+ sequence: sequence,
154
+ boundary_id: boundary_id,
155
+ occurred_at: occurred_at,
156
+ metadata: metadata,
157
+ idempotency_key: idempotency_key
158
+ )
159
+
160
+ index_stats = {}
161
+ Llmemory::Instrumentation.instrument(
162
+ :trace_write,
163
+ user_id: @user_id,
164
+ session_id: @session_id,
165
+ trace_id: trace.id,
166
+ sequence: sequence,
167
+ role: trace.role,
168
+ maintenance_fanout: state_key ? 2 : 1
169
+ ) do
170
+ @trace_store.write_trace(trace)
171
+ if state_key
172
+ effective_from = valid_from || trace.occurred_at
173
+ if supersedes_trace_id
174
+ @trace_store.close_open_state_links(
175
+ @user_id,
176
+ state_key,
177
+ valid_to: effective_from,
178
+ except_trace_id: trace.id
179
+ )
180
+ end
181
+ link = ZeroMem::TraceStateLink.build(
182
+ user_id: @user_id,
183
+ state_key: state_key,
184
+ trace_id: trace.id,
185
+ valid_from: effective_from,
186
+ valid_to: valid_to,
187
+ supersedes_trace_id: supersedes_trace_id,
188
+ source: source
189
+ )
190
+ @trace_store.write_state_link(link)
191
+ Llmemory::Instrumentation.instrument(
192
+ :zero_mem_state_update,
193
+ user_id: @user_id,
194
+ state_key: state_key,
195
+ trace_id: trace.id,
196
+ supersedes_trace_id: supersedes_trace_id
197
+ )
198
+ end
199
+ index_stats = @trace_indexer.after_trace_write(
200
+ trace: trace,
201
+ state_link: !state_key.nil?
202
+ )
115
203
  end
204
+
205
+ append_message_to_checkpoint(role: trace.role, content: trace.content) if add_to_checkpoint
206
+ trace.id
207
+ end
208
+
209
+ def retrieve_evidence(query, top_k: nil, max_tokens: nil, boundary: nil, explain: false, current_trace_id: nil,
210
+ **opts)
211
+ raise ConfigurationError, "retrieve_evidence requires memory_mode :zero_mem or :hybrid" unless zero_mem_enabled?
212
+ raise ConfigurationError, "trace_store is not configured" unless @trace_store
213
+
214
+ invoke_before = generative_invoke_calls
215
+ result = zero_mem_engine.retrieve_evidence(
216
+ query,
217
+ top_k: top_k,
218
+ max_tokens: max_tokens,
219
+ boundary: boundary,
220
+ explain: explain,
221
+ current_trace_id: current_trace_id,
222
+ **opts
223
+ )
224
+ compliant = generative_invoke_delta(invoke_before).zero?
225
+ result.metrics[:zero_mem_compliant] = compliant if zero_mem_strict?
226
+ result
227
+ end
228
+
229
+ def calibrate_answer(answer, evidence_result:)
230
+ ZeroMem::AnswerCalibrator.new.calibrate(answer, evidence_result: evidence_result)
231
+ end
232
+
233
+ def forget_traces!(trace_ids, reason: nil)
234
+ return false unless @trace_store
235
+
236
+ ids = Array(trace_ids).map(&:to_s)
237
+ ids.each { |id| @trace_store.archive_trace(@user_id, id) }
238
+ forget_log.record(
239
+ @user_id,
240
+ memory_type: ZeroMem::MEMORY_TYPE,
241
+ ids: ids,
242
+ reason: reason
243
+ )
116
244
  true
117
245
  end
118
246
 
247
+ attr_reader :trace_store
248
+
119
249
  def messages
120
250
  state = @checkpoint.restore_state
121
251
  return [] unless state.is_a?(Hash)
@@ -125,6 +255,21 @@ module Llmemory
125
255
  end
126
256
 
127
257
  def retrieve(query, max_tokens: nil)
258
+ if zero_mem_enabled? && @trace_store
259
+ invoke_before = generative_invoke_calls
260
+ msgs = pruned_messages
261
+ short_context = format_short_term_context(msgs)
262
+ evidence_context = zero_mem_engine.to_context(query, max_tokens: max_tokens)
263
+ combined = combine_contexts(short_context, evidence_context)
264
+ compliant = generative_invoke_delta(invoke_before).zero?
265
+ Llmemory::Instrumentation.instrument(
266
+ :retrieve,
267
+ query_chars: query.to_s.length,
268
+ zero_mem_compliant: zero_mem_strict? ? compliant : false
269
+ )
270
+ return combined
271
+ end
272
+
128
273
  msgs = pruned_messages
129
274
  short_context = format_short_term_context(msgs)
130
275
  long_context = @retrieval_engine.retrieve_for_inference(query, user_id: @user_id, max_tokens: max_tokens)
@@ -162,6 +307,7 @@ module Llmemory
162
307
  end
163
308
 
164
309
  def consolidate!
310
+ deny_generative!(:consolidate!) if zero_mem_strict?
165
311
  msgs = messages
166
312
  return true if msgs.empty?
167
313
  conversation_text = msgs.map { |m| format_message(m) }.join("\n")
@@ -177,6 +323,8 @@ module Llmemory
177
323
 
178
324
  def compact!(max_bytes: nil)
179
325
  max = max_bytes || Llmemory.configuration.compact_max_bytes
326
+ return compact_trace_deterministic!(max) if trace_backed? || zero_mem_strict?
327
+
180
328
  msgs = messages
181
329
  current_bytes = messages_byte_size(msgs)
182
330
  return false if current_bytes <= max
@@ -195,6 +343,7 @@ module Llmemory
195
343
  end
196
344
 
197
345
  def maybe_flush_memory!
346
+ return false if zero_mem_strict?
198
347
  return false unless Llmemory.configuration.memory_flush_enabled
199
348
  msgs = messages
200
349
  return false if msgs.empty?
@@ -241,6 +390,11 @@ module Llmemory
241
390
  def check_context_window!
242
391
  return false if messages.empty?
243
392
 
393
+ if zero_mem_strict?
394
+ return compact! if should_compact?
395
+ return false
396
+ end
397
+
244
398
  flushed = false
245
399
  if should_auto_consolidate? && Llmemory.configuration.memory_flush_enabled
246
400
  consolidate!
@@ -255,6 +409,50 @@ module Llmemory
255
409
  flushed || compacted
256
410
  end
257
411
 
412
+ def zero_mem_status(session_id: @session_id)
413
+ raise ConfigurationError, "trace_store is not configured" unless @trace_store
414
+
415
+ wm = @trace_store.get_watermark(@user_id, session_id)
416
+ traces = @trace_store.list_traces(@user_id, session_id: session_id)
417
+ last_seq = traces.map(&:sequence).max.to_i
418
+ lag = [last_seq - wm[:last_sequence].to_i, 0].max
419
+ {
420
+ memory_mode: @memory_mode,
421
+ session_id: session_id.to_s,
422
+ trace_count: traces.size,
423
+ last_sequence: last_seq,
424
+ watermark_sequence: wm[:last_sequence].to_i,
425
+ index_version: wm[:index_version].to_i,
426
+ index_lag: lag
427
+ }
428
+ end
429
+
430
+ def reindex_traces!(session_id: @session_id)
431
+ raise ConfigurationError, "trace_store is not configured" unless @trace_store
432
+
433
+ traces = @trace_store.list_traces(@user_id, session_id: session_id)
434
+ traces.each do |trace|
435
+ @trace_indexer.after_trace_write(trace: trace)
436
+ end
437
+ zero_mem_status(session_id: session_id)
438
+ end
439
+
440
+ def memory_mode
441
+ @memory_mode
442
+ end
443
+
444
+ def zero_mem_enabled?
445
+ ZeroMem::Mode.zero_mem_enabled?(@memory_mode)
446
+ end
447
+
448
+ def zero_mem_strict?
449
+ ZeroMem::Mode.zero_mem_strict?(@memory_mode)
450
+ end
451
+
452
+ def shadow_write_enabled?
453
+ @memory_mode == :classic && Llmemory.configuration.zero_mem_shadow_write
454
+ end
455
+
258
456
  def user_id
259
457
  @user_id
260
458
  end
@@ -265,6 +463,43 @@ module Llmemory
265
463
 
266
464
  private
267
465
 
466
+ def forget_log
467
+ @forget_log ||= ForgetLog.new(store: @short_term_store)
468
+ end
469
+
470
+ def zero_mem_engine
471
+ @zero_mem_engine ||= ZeroMem::Engine.new(trace_store: @trace_store, user_id: @user_id)
472
+ end
473
+
474
+ def trace_backed?
475
+ !@trace_store.nil?
476
+ end
477
+
478
+ def compact_trace_deterministic!(max_bytes)
479
+ msgs = messages
480
+ current_bytes = messages_byte_size(msgs)
481
+ return false if current_bytes <= max_bytes
482
+
483
+ old_msgs, recent_msgs = split_messages_by_bytes(msgs, max_bytes)
484
+ return false if old_msgs.empty?
485
+
486
+ state = restore_state_for_save
487
+ flush_ts = state[:last_flush_at] || state["last_flush_at"]
488
+ save_state(messages: recent_msgs, last_compact_at: Time.now, last_flush_at: flush_ts)
489
+ true
490
+ end
491
+
492
+ def append_message_to_checkpoint(role:, content:)
493
+ @short_term_store.update(@user_id, @session_id) do |state|
494
+ state = normalize_state_hash(state)
495
+ list = state[STATE_KEY_MESSAGES]
496
+ list = list.is_a?(Array) ? list.dup : []
497
+ list << { role: role.to_sym, content: content.to_s }
498
+ list = sanitize_messages(list) if Llmemory.configuration.message_sanitizer_enabled
499
+ state.merge(STATE_KEY_MESSAGES => list, last_activity_at: Time.now, **preserved_flush_state_from(state))
500
+ end
501
+ end
502
+
268
503
  def summarize_messages(msgs)
269
504
  conversation = msgs.map { |m| format_message(m) }.join("\n")
270
505
  prompt = <<~PROMPT
@@ -293,7 +528,20 @@ module Llmemory
293
528
  )
294
529
  end
295
530
 
531
+ def deny_generative!(operation)
532
+ raise GenerativeOperationDisabled, "#{operation} is disabled when memory_mode is :zero_mem"
533
+ end
534
+
535
+ def generative_invoke_calls
536
+ llm_usage.dig(:invoke, :calls).to_i
537
+ end
538
+
539
+ def generative_invoke_delta(before)
540
+ generative_invoke_calls - before.to_i
541
+ end
542
+
296
543
  def flush_memory_before_compaction!(msgs)
544
+ return false if zero_mem_strict?
297
545
  return false unless Llmemory.configuration.memory_flush_enabled
298
546
  return false if msgs.empty?
299
547
  return false if estimated_tokens(msgs) < Llmemory.configuration.memory_flush_threshold_tokens
@@ -11,6 +11,11 @@ module Llmemory
11
11
  @b = b
12
12
  end
13
13
 
14
+ # Score arbitrary documents `{ id:, text: }` (or candidates with :text).
15
+ def score_documents(query, documents)
16
+ score_candidates(query, documents)
17
+ end
18
+
14
19
  def score_candidates(query, candidates)
15
20
  return [] if candidates.empty?
16
21
 
@@ -14,13 +14,15 @@ module Llmemory
14
14
 
15
15
  ranked_memories.each do |memory|
16
16
  text = memory[:text] || memory["text"] || ""
17
+ text = label_volatile(text) if volatile_memory?(memory)
17
18
  memory_tokens = count_tokens(text)
18
19
  break if token_count + memory_tokens > max_tokens
19
20
 
20
21
  selected << {
21
22
  text: text,
22
23
  timestamp: memory[:timestamp] || memory["timestamp"],
23
- confidence: memory[:temporal_score] || memory[:score] || memory["score"]
24
+ confidence: memory[:temporal_score] || memory[:score] || memory["score"],
25
+ volatile: volatile_memory?(memory)
24
26
  }
25
27
  token_count += memory_tokens
26
28
  end
@@ -48,6 +50,20 @@ module Llmemory
48
50
  lines << "=== END MEMORIES ==="
49
51
  lines.join("\n")
50
52
  end
53
+
54
+ def volatile_memory?(memory)
55
+ memory[:volatile] == true || memory["volatile"] == true
56
+ end
57
+
58
+ def label_volatile_enabled?
59
+ Llmemory.configuration.retrieval_label_volatile
60
+ end
61
+
62
+ def label_volatile(text)
63
+ return text unless label_volatile_enabled?
64
+
65
+ "[verify live] #{text}"
66
+ end
51
67
  end
52
68
  end
53
69
  end
@@ -2,25 +2,35 @@
2
2
 
3
3
  module Llmemory
4
4
  # Shared word tokenizer for keyword search and lexical scoring (BM25, MMR).
5
- # Centralizes the tokenization regex that was duplicated across the codebase.
5
+ # Unicode-aware (NFKC, accented letters, CJK, digits).
6
6
  module Tokenizer
7
7
  module_function
8
8
 
9
- WORD = /\b[a-z0-9]{2,}\b/
9
+ # Latin letters (2+), single Han ideographs, digit runs.
10
+ TOKEN_PATTERN = /\d+|\p{Han}+|[\p{L}&&[^\p{Han}]]{2,}/u
11
+
12
+ def normalize(text)
13
+ str = text.to_s
14
+ unless str.encoding == Encoding::UTF_8 && str.valid_encoding?
15
+ str = str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
16
+ end
17
+ str.unicode_normalize(:nfkc).downcase
18
+ end
10
19
 
11
20
  def tokenize(text)
12
- text.to_s.downcase.scan(WORD)
21
+ normalized = normalize(text)
22
+ return [] if normalized.strip.empty?
23
+
24
+ normalized.scan(TOKEN_PATTERN)
13
25
  end
14
26
 
15
27
  # Lexical match used by storage-level keyword search. A query is split into
16
- # tokens and matched as an OR of per-token substrings, so multi-word queries
17
- # work (a single contiguous substring of the whole query is no longer
18
- # required) while single-term/partial matches are preserved. An empty query
19
- # (no tokens) matches everything, keeping prior "return all" behavior.
28
+ # tokens and matched as an OR of per-token substrings.
20
29
  def matches?(text, query)
21
30
  tokens = tokenize(query)
22
31
  return true if tokens.empty?
23
- haystack = text.to_s.downcase
32
+
33
+ haystack = normalize(text)
24
34
  tokens.any? { |t| haystack.include?(t) }
25
35
  end
26
36
  end
@@ -12,10 +12,14 @@ module Llmemory
12
12
  end
13
13
 
14
14
  def embed(text)
15
- return Array.new(1536, 0.0) unless @embedding_provider&.respond_to?(:embed)
15
+ return Array.new(default_dimensions, 0.0) unless @embedding_provider&.respond_to?(:embed)
16
16
  @embedding_provider.embed(text)
17
17
  end
18
18
 
19
+ def embedding_dimensions
20
+ default_dimensions
21
+ end
22
+
19
23
  def last_usage
20
24
  return @embedding_provider.last_usage if @embedding_provider&.respond_to?(:last_usage)
21
25
 
@@ -23,6 +27,7 @@ module Llmemory
23
27
  end
24
28
 
25
29
  def store(id:, embedding:, metadata: {}, user_id: nil)
30
+ validate_dimensions!(embedding)
26
31
  key = user_id ? "#{user_id}:#{id}" : id.to_s
27
32
  meta = (metadata || {}).dup
28
33
  if meta["text"] && @cipher.enabled?
@@ -40,6 +45,7 @@ module Llmemory
40
45
 
41
46
  def search(query_embedding, top_k: 10, user_id: nil)
42
47
  query = query_embedding.to_a.map(&:to_f)
48
+ validate_dimensions!(query)
43
49
  return [] if query.empty?
44
50
  entries = user_id ? @entries.select { |k, _| k.to_s.start_with?("#{user_id}:") } : @entries
45
51
  scores = entries.map do |_key, data|
@@ -79,6 +85,22 @@ module Llmemory
79
85
  out
80
86
  end
81
87
 
88
+ def default_dimensions
89
+ if @embedding_provider.respond_to?(:dimensions)
90
+ @embedding_provider.dimensions
91
+ else
92
+ 1536
93
+ end
94
+ end
95
+
96
+ def validate_dimensions!(vector)
97
+ dims = default_dimensions
98
+ size = vector.to_a.size
99
+ return if size == dims
100
+
101
+ raise ConfigurationError, "embedding dimension mismatch: expected #{dims}, got #{size}"
102
+ end
103
+
82
104
  def cosine_similarity(a, b)
83
105
  return 0.0 if a.size != b.size || a.empty?
84
106
  dot = a.zip(b).sum { |x, y| x * y }
@@ -14,37 +14,59 @@ module Llmemory
14
14
  DEFAULT_MODEL = "text-embedding-3-small"
15
15
  DEFAULT_DIMS = 1536
16
16
 
17
- attr_reader :last_usage
17
+ attr_reader :last_usage, :model
18
18
 
19
- def initialize(api_key: nil, model: nil, base_url: nil)
19
+ def initialize(api_key: nil, model: nil, base_url: nil, dimensions: nil)
20
20
  @api_key = api_key || Llmemory.configuration.llm_api_key
21
21
  @model = model || DEFAULT_MODEL
22
+ @dimensions = dimensions || DEFAULT_DIMS
22
23
  @base_url = base_url || Llmemory.configuration.llm_base_url || "https://api.openai.com/v1"
23
24
  @cache = {}
24
25
  @cache_order = []
25
26
  @last_usage = Llmemory::LLM::Usage.zero
26
27
  end
27
28
 
29
+ def dimensions
30
+ @dimensions
31
+ end
32
+
33
+ def local?
34
+ false
35
+ end
36
+
28
37
  def embed(text)
29
- return Array.new(DEFAULT_DIMS, 0.0) if text.to_s.strip.empty?
38
+ embed_batch([text]).first
39
+ end
30
40
 
31
- if Llmemory.configuration.embedding_cache_enabled
32
- key = cache_key(text)
33
- if @cache.key?(key)
41
+ def embed_batch(texts)
42
+ list = Array(texts).map(&:to_s)
43
+ return [] if list.empty?
44
+
45
+ results = list.map do |text|
46
+ if text.strip.empty?
47
+ Array.new(dimensions, 0.0)
48
+ elsif Llmemory.configuration.embedding_cache_enabled && @cache.key?(cache_key(text))
34
49
  @last_usage = Llmemory::LLM::Usage.zero
35
- return @cache[key].dup
50
+ @cache[cache_key(text)].dup
51
+ else
52
+ :miss
36
53
  end
37
54
  end
38
55
 
39
- result = fetch_embedding(text)
40
-
41
- if Llmemory.configuration.embedding_cache_enabled
42
- evict_if_needed
43
- @cache[cache_key(text)] = result.dup
44
- @cache_order << cache_key(text)
56
+ miss_indices = results.each_index.select { |i| results[i] == :miss }
57
+ if miss_indices.any?
58
+ fetched = fetch_embeddings(miss_indices.map { |i| list[i] })
59
+ miss_indices.each_with_index do |idx, j|
60
+ vec = fetched[j]
61
+ results[idx] = vec
62
+ if Llmemory.configuration.embedding_cache_enabled
63
+ evict_if_needed
64
+ @cache[cache_key(list[idx])] = vec.dup
65
+ @cache_order << cache_key(list[idx])
66
+ end
67
+ end
45
68
  end
46
-
47
- result
69
+ results
48
70
  end
49
71
 
50
72
  private
@@ -63,14 +85,16 @@ module Llmemory
63
85
  end
64
86
  end
65
87
 
66
- def fetch_embedding(text)
67
- result = nil
68
- payload = { provider: :openai, model: @model, text_chars: text.to_s.length }
88
+ def fetch_embeddings(texts)
89
+ payload = { provider: :openai, model: @model, text_chars: texts.sum(&:length) }
90
+ vectors = nil
69
91
  Llmemory::Instrumentation.instrument(:llm_embed, payload) do
92
+ body_input = { input: texts.map { |t| t.to_s.strip }, model: @model }
93
+ body_input[:dimensions] = @dimensions if @dimensions != DEFAULT_DIMS
70
94
  response = post_with_resilience(connection, "embeddings") do |req|
71
95
  req.headers["Authorization"] = "Bearer #{@api_key}"
72
96
  req.headers["Content-Type"] = "application/json"
73
- req.body = { input: text.to_s.strip, model: @model }.to_json
97
+ req.body = body_input.to_json
74
98
  end
75
99
  raise Llmemory::LLMError, "OpenAI Embeddings API error: #{response.body}" unless response.success?
76
100
  body = response.body.is_a?(Hash) ? response.body : JSON.parse(response.body.to_s)
@@ -80,9 +104,11 @@ module Llmemory
80
104
  output_tokens: @last_usage.output_tokens,
81
105
  total_tokens: @last_usage.total_tokens
82
106
  )
83
- result = body.dig("data", 0, "embedding")&.map(&:to_f) || Array.new(DEFAULT_DIMS, 0.0)
107
+ vectors = Array(body["data"]).sort_by { |row| row["index"] }.map do |row|
108
+ row["embedding"]&.map(&:to_f) || Array.new(dimensions, 0.0)
109
+ end
84
110
  end
85
- result
111
+ vectors
86
112
  end
87
113
 
88
114
  def parse_embed_usage(raw)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Llmemory
4
- VERSION = "0.2.10"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class AnswerCalibrator
6
+ def calibrate(answer, evidence_result:)
7
+ answer = answer.to_s
8
+ evidence = Array(evidence_result&.evidence)
9
+ checks = []
10
+ unsupported = []
11
+
12
+ if list_like?(answer)
13
+ checks << "list_answer"
14
+ return unchanged(answer, checks, unsupported)
15
+ end
16
+
17
+ scalar = extract_scalar_candidate(answer)
18
+ if scalar
19
+ matches = evidence.select { |ev| ev.content.to_s.include?(scalar) }
20
+ checks << "scalar_support"
21
+ if matches.size == 1
22
+ return {
23
+ answer: scalar,
24
+ changed: answer.strip != scalar,
25
+ checks: checks,
26
+ unsupported_fragments: unsupported
27
+ }
28
+ end
29
+
30
+ checks << "ambiguous_scalar"
31
+ return unchanged(answer, checks, unsupported)
32
+ end
33
+
34
+ checks << "free_text"
35
+ unchanged(answer, checks, unsupported)
36
+ end
37
+
38
+ private
39
+
40
+ def unchanged(answer, checks, unsupported)
41
+ {
42
+ answer: answer,
43
+ changed: false,
44
+ checks: checks,
45
+ unsupported_fragments: unsupported
46
+ }
47
+ end
48
+
49
+ def extract_scalar_candidate(answer)
50
+ stripped = answer.strip
51
+ return stripped if stripped.match?(/\A[\d.]+\z/)
52
+ return stripped if stripped.length <= 32 && stripped !~ /\s/
53
+
54
+ m = stripped.match(/\b([A-Z]{1,3})\b/)
55
+ m&.[](1)
56
+ end
57
+
58
+ def list_like?(answer)
59
+ answer.include?(",") || answer.match?(/\b(and|y)\b/i)
60
+ end
61
+ end
62
+ end
63
+ end