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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../store_helpers"
4
+
3
5
  module Llmemory
4
6
  module MCP
5
7
  module Tools
@@ -26,7 +28,8 @@ module Llmemory
26
28
  opts[:reflection_window] = reflection_window.to_i unless reflection_window.nil?
27
29
  opts[:mining_window] = mining_window.to_i unless mining_window.nil?
28
30
 
29
- report = Llmemory::Maintenance::CognitivePass.run!(user_id, **opts)
31
+ memory = StoreHelpers.build_memory(user_id: user_id)
32
+ report = Llmemory::Maintenance::CognitivePass.run!(user_id, memory: memory, **opts)
30
33
  ::MCP::Tool::Response.new([{ type: "text", text: format_report(user_id, report) }])
31
34
  rescue => e
32
35
  ::MCP::Tool::Response.new([{ type: "text", text: "Error running maintenance pass: #{e.message}" }], error: true)
@@ -42,6 +45,8 @@ module Llmemory
42
45
  " skills mined: #{Array(report[:mined]).size}",
43
46
  " expired: episodic=#{expired[:episodic] || 0} procedural=#{expired[:procedural] || 0}"
44
47
  ]
48
+ disabled = Array(report[:disabled])
49
+ lines << " disabled: #{disabled.join(', ')}" if disabled.any?
45
50
  errors = report[:errors] || {}
46
51
  lines << " errors: #{errors.map { |k, v| "#{k}: #{v}" }.join('; ')}" unless errors.empty?
47
52
  lines.join("\n")
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../store_helpers"
4
+
3
5
  module Llmemory
4
6
  module MCP
5
7
  module Tools
@@ -25,7 +27,7 @@ module Llmemory
25
27
  include_timeline = include_timeline_context == true
26
28
  window = timeline_window || 3
27
29
 
28
- memory = Llmemory::Memory.new(user_id: user_id, session_id: session)
30
+ memory = StoreHelpers.build_memory(user_id: user_id, session_id: session)
29
31
  context = memory.retrieve(query, max_tokens: tokens)
30
32
 
31
33
  # Add timeline context if requested
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../store_helpers"
5
+
6
+ module Llmemory
7
+ module MCP
8
+ module Tools
9
+ class MemoryRetrieveEvidence < ::MCP::Tool
10
+ description "Retrieve structured Zero-Mem evidence (traces) for a query without generative LLM calls. Returns JSON plus a text context block."
11
+
12
+ input_schema(
13
+ properties: {
14
+ query: { type: "string", description: "Evidence query" },
15
+ user_id: { type: "string", description: "User identifier" },
16
+ session_id: { type: "string", description: "Session identifier (default: 'default')" },
17
+ top_k: { type: "integer", description: "Maximum seed traces (default from config)" },
18
+ max_tokens: { type: "integer", description: "Token budget for assembled evidence" },
19
+ boundary: { type: "object", description: "Optional session/boundary filter" },
20
+ explain: { type: "boolean", description: "Include routing/fusion explain payload (default false)" }
21
+ },
22
+ required: ["query", "user_id"]
23
+ )
24
+
25
+ class << self
26
+ def call(query:, user_id:, session_id: nil, top_k: nil, max_tokens: nil, boundary: nil,
27
+ explain: false, server_context: nil)
28
+ unless ZeroMem::Mode.zero_mem_enabled?(Llmemory.configuration.memory_mode)
29
+ return error_response("memory_retrieve_evidence requires memory_mode :zero_mem or :hybrid")
30
+ end
31
+
32
+ session = session_id || "default"
33
+ memory = StoreHelpers.build_memory(user_id: user_id, session_id: session)
34
+ result = memory.retrieve_evidence(
35
+ query,
36
+ top_k: top_k,
37
+ max_tokens: max_tokens,
38
+ boundary: boundary,
39
+ explain: explain == true
40
+ )
41
+
42
+ payload = {
43
+ route: result.route,
44
+ profile: result.profile.to_h,
45
+ evidence: result.evidence.map(&:to_h),
46
+ seed_evidence: result.seed_evidence.map(&:to_h),
47
+ closure_evidence: result.closure_evidence.map(&:to_h),
48
+ metrics: result.metrics,
49
+ degraded: result.degraded,
50
+ warnings: result.warnings,
51
+ explain: result.explain,
52
+ context: result.to_context
53
+ }
54
+
55
+ ::MCP::Tool::Response.new([{
56
+ type: "text",
57
+ text: JSON.pretty_generate(payload)
58
+ }])
59
+ rescue => e
60
+ error_response(e.message)
61
+ end
62
+
63
+ def error_response(message)
64
+ ::MCP::Tool::Response.new([{ type: "text", text: "Error retrieving evidence: #{message}" }], error: true)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../store_helpers"
4
+
3
5
  module Llmemory
4
6
  module MCP
5
7
  module Tools
@@ -53,6 +55,16 @@ module Llmemory
53
55
  end
54
56
 
55
57
  stats[:llm_usage] = Llmemory::LLM::UsageLedger.new(store: store).totals(user_id)
58
+ stats[:memory_mode] = Llmemory.configuration.memory_mode
59
+ if ZeroMem::Mode.zero_mem_enabled?(Llmemory.configuration.memory_mode)
60
+ memory = StoreHelpers.build_memory(user_id: user_id)
61
+ stats[:zero_mem] = memory.zero_mem_status
62
+ invoke_calls = stats[:llm_usage].dig(:invoke, :calls).to_i
63
+ stats[:zero_mem_compliant] = ZeroMem::Mode.zero_mem_strict?(Llmemory.configuration.memory_mode) &&
64
+ invoke_calls.zero?
65
+ else
66
+ stats[:zero_mem_compliant] = false
67
+ end
56
68
 
57
69
  ::MCP::Tool::Response.new([{
58
70
  type: "text",
@@ -106,6 +118,16 @@ module Llmemory
106
118
 
107
119
  output << ""
108
120
  output << Llmemory::LLM::UsageLedger.format_text(stats[:llm_usage] || default_llm_usage)
121
+ output << ""
122
+ output << "MEMORY MODE: #{stats[:memory_mode]}"
123
+ if stats[:zero_mem]
124
+ zm = stats[:zero_mem]
125
+ output << "ZERO-MEM:"
126
+ output << " traces: #{zm[:trace_count]}"
127
+ output << " index_lag: #{zm[:index_lag]}"
128
+ output << " index_version: #{zm[:index_version]}"
129
+ end
130
+ output << " zero_mem_compliant: #{stats[:zero_mem_compliant]}"
109
131
 
110
132
  output.join("\n")
111
133
  end
@@ -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
 
@@ -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 }