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.
- checksums.yaml +4 -4
- data/README.md +48 -0
- data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +71 -0
- data/lib/generators/llmemory/upgrade_zero_mem/templates/create_llmemory_zero_mem_tables.rb +77 -0
- data/lib/generators/llmemory/upgrade_zero_mem/upgrade_zero_mem_generator.rb +19 -0
- data/lib/llmemory/cli/commands/zero_mem.rb +107 -0
- data/lib/llmemory/cli.rb +3 -0
- data/lib/llmemory/configuration.rb +67 -1
- data/lib/llmemory/consolidation/filter.rb +74 -0
- data/lib/llmemory/consolidation/policy.rb +17 -0
- data/lib/llmemory/consolidation/rule_based_policy.rb +63 -0
- data/lib/llmemory/consolidation/unit.rb +54 -0
- data/lib/llmemory/consolidation.rb +11 -0
- data/lib/llmemory/instrumentation.rb +10 -1
- data/lib/llmemory/long_term/file_based/memory.rb +18 -7
- data/lib/llmemory/long_term/graph_based/memory.rb +31 -8
- data/lib/llmemory/maintenance/cognitive_pass.rb +29 -1
- data/lib/llmemory/maintenance/policy_prune.rb +85 -0
- data/lib/llmemory/maintenance/zero_mem_ttl.rb +26 -0
- data/lib/llmemory/maintenance.rb +2 -0
- data/lib/llmemory/mcp/server.rb +2 -0
- data/lib/llmemory/mcp/store_helpers.rb +16 -0
- data/lib/llmemory/mcp/tools/memory_add_message.rb +17 -4
- data/lib/llmemory/mcp/tools/memory_consolidate.rb +9 -4
- data/lib/llmemory/mcp/tools/memory_maintain.rb +6 -1
- data/lib/llmemory/mcp/tools/memory_retrieve.rb +3 -1
- data/lib/llmemory/mcp/tools/memory_retrieve_evidence.rb +70 -0
- data/lib/llmemory/mcp/tools/memory_stats.rb +22 -0
- data/lib/llmemory/memory.rb +257 -9
- data/lib/llmemory/retrieval/bm25_scorer.rb +5 -0
- data/lib/llmemory/retrieval/context_assembler.rb +17 -1
- data/lib/llmemory/tokenizer.rb +18 -8
- data/lib/llmemory/vector_store/memory_store.rb +23 -1
- data/lib/llmemory/vector_store/openai_embeddings.rb +47 -21
- data/lib/llmemory/version.rb +1 -1
- data/lib/llmemory/zero_mem/answer_calibrator.rb +63 -0
- data/lib/llmemory/zero_mem/backfill.rb +43 -0
- data/lib/llmemory/zero_mem/budget_assembler.rb +62 -0
- data/lib/llmemory/zero_mem/circuit_breaker.rb +37 -0
- data/lib/llmemory/zero_mem/embedding_provider.rb +73 -0
- data/lib/llmemory/zero_mem/engine.rb +247 -0
- data/lib/llmemory/zero_mem/entity.rb +31 -0
- data/lib/llmemory/zero_mem/entity_extractor.rb +23 -0
- data/lib/llmemory/zero_mem/entity_index.rb +65 -0
- data/lib/llmemory/zero_mem/entity_mention.rb +35 -0
- data/lib/llmemory/zero_mem/errors.rb +7 -0
- data/lib/llmemory/zero_mem/evidence.rb +43 -0
- data/lib/llmemory/zero_mem/evidence_calibrator.rb +158 -0
- data/lib/llmemory/zero_mem/evidence_closure.rb +98 -0
- data/lib/llmemory/zero_mem/evidence_fusion.rb +57 -0
- data/lib/llmemory/zero_mem/evidence_set.rb +54 -0
- data/lib/llmemory/zero_mem/extractors/heuristic.rb +44 -0
- data/lib/llmemory/zero_mem/extractors/http.rb +58 -0
- data/lib/llmemory/zero_mem/graph_retriever.rb +107 -0
- data/lib/llmemory/zero_mem/hierarchy_builder.rb +123 -0
- data/lib/llmemory/zero_mem/hierarchy_retriever.rb +133 -0
- data/lib/llmemory/zero_mem/indexer.rb +73 -0
- data/lib/llmemory/zero_mem/mode.rb +30 -0
- data/lib/llmemory/zero_mem/pagerank.rb +42 -0
- data/lib/llmemory/zero_mem/query_profile.rb +48 -0
- data/lib/llmemory/zero_mem/query_profiler.rb +138 -0
- data/lib/llmemory/zero_mem/repair.rb +31 -0
- data/lib/llmemory/zero_mem/router.rb +69 -0
- data/lib/llmemory/zero_mem/sidecar_client.rb +82 -0
- data/lib/llmemory/zero_mem/storage.rb +124 -0
- data/lib/llmemory/zero_mem/storages/active_record.rb +311 -0
- data/lib/llmemory/zero_mem/storages/active_record_models.rb +21 -0
- data/lib/llmemory/zero_mem/storages/file.rb +67 -0
- data/lib/llmemory/zero_mem/storages/memory.rb +294 -0
- data/lib/llmemory/zero_mem/storages/postgres.rb +85 -0
- data/lib/llmemory/zero_mem/storages/snapshot_codec.rb +180 -0
- data/lib/llmemory/zero_mem/storages.rb +43 -0
- data/lib/llmemory/zero_mem/trace.rb +100 -0
- data/lib/llmemory/zero_mem/trace_state_link.rb +64 -0
- data/lib/llmemory/zero_mem/trace_unit.rb +83 -0
- data/lib/llmemory/zero_mem.rb +42 -0
- data/lib/llmemory.rb +8 -0
- data/lib/tasks/llmemory.rake +84 -2
- metadata +54 -2
data/lib/llmemory/memory.rb
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
data/lib/llmemory/tokenizer.rb
CHANGED
|
@@ -2,25 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
module Llmemory
|
|
4
4
|
# Shared word tokenizer for keyword search and lexical scoring (BM25, MMR).
|
|
5
|
-
#
|
|
5
|
+
# Unicode-aware (NFKC, accented letters, CJK, digits).
|
|
6
6
|
module Tokenizer
|
|
7
7
|
module_function
|
|
8
8
|
|
|
9
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
38
|
+
embed_batch([text]).first
|
|
39
|
+
end
|
|
30
40
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
50
|
+
@cache[cache_key(text)].dup
|
|
51
|
+
else
|
|
52
|
+
:miss
|
|
36
53
|
end
|
|
37
54
|
end
|
|
38
55
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
111
|
+
vectors
|
|
86
112
|
end
|
|
87
113
|
|
|
88
114
|
def parse_embed_usage(raw)
|
data/lib/llmemory/version.rb
CHANGED
|
@@ -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
|