lex-agentic-memory 0.1.31 → 0.1.33
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/CHANGELOG.md +11 -0
- data/lib/legion/extensions/agentic/memory/trace/helpers/postgres_store.rb +6 -3
- data/lib/legion/extensions/agentic/memory/trace/helpers/store.rb +25 -6
- data/lib/legion/extensions/agentic/memory/version.rb +1 -1
- data/spec/legion/extensions/agentic/memory/trace/helpers/postgres_store_spec.rb +57 -0
- data/spec/legion/extensions/agentic/memory/trace/helpers/store_spec.rb +86 -0
- data/spec/legion/extensions/agentic/memory/trace/local_persistence_spec.rb +17 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 01630a8c5fd981fb85c69337d299f71dc215b79b9ce3dca7a9b0aef57545fde3
|
|
4
|
+
data.tar.gz: 6eb6bd8f8e924aacb9a805561f9f39c0ac18b5d1f559faa7f58a1af3a6d3ad8a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 69c12558b2cb6d7a883a9f0bd345ad0934273847b1a44f2dcaed02682a347d9a88dfddbbf6b2ab48d91a418c83f137eb585723dc3aecf19c0a2fb2e1454e7bd3
|
|
7
|
+
data.tar.gz: 8200e4e37b770d4afa344153cd6dc95b9bd047dd55d34079b1bfc6c87c7f261e0049ee2c6f77e4f2b04bf7455745f88eb06160ad006eb81dda7ee0d2053093f9
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.33] - 2026-05-07
|
|
4
|
+
### Fixed
|
|
5
|
+
- `PostgresStore#parse_json_or_raw` no longer attempts JSON parse on plain-text content — checks for `{`/`[` prefix before parsing, eliminating ERROR log spam during bulk reads with mixed content types
|
|
6
|
+
- `PostgresStore#parse_json_array` now guards against nil/empty strings before parsing
|
|
7
|
+
- `Store#parse_db_json` now short-circuits on nil/empty columns (domain_tags, associations, child_ids, emotional_valence) before attempting parse, fixing ERROR spam on traces with sparse optional fields
|
|
8
|
+
|
|
9
|
+
## [0.1.32] - 2026-05-07
|
|
10
|
+
### Fixed
|
|
11
|
+
- Trace association retrieval now snapshots associated traces under the store mutex before filtering.
|
|
12
|
+
- Local trace restore preserves symbol keys for JSON fields that are consumed as symbol-keyed hashes.
|
|
13
|
+
|
|
3
14
|
## [0.1.31] - 2026-04-27
|
|
4
15
|
### Added
|
|
5
16
|
- Add a heuristic pre-compaction memory save path and synchronous pre-compaction event listeners for `chat.pre_compact`, `context.pre_compact`, and `conversation.pre_compact`.
|
|
@@ -394,15 +394,18 @@ module Legion
|
|
|
394
394
|
def parse_json_or_raw(raw)
|
|
395
395
|
return raw unless raw.is_a?(String)
|
|
396
396
|
|
|
397
|
-
|
|
398
|
-
|
|
397
|
+
stripped = raw.strip
|
|
398
|
+
return raw unless stripped.start_with?('{', '[')
|
|
399
|
+
|
|
400
|
+
parsed = Legion::JSON.load(stripped)
|
|
401
|
+
parsed.is_a?(Hash) || parsed.is_a?(Array) ? parsed : raw
|
|
399
402
|
rescue StandardError => e
|
|
400
403
|
log.error "[trace_persistence] parse_json_or_raw: #{e.message}"
|
|
401
404
|
raw
|
|
402
405
|
end
|
|
403
406
|
|
|
404
407
|
def parse_json_array(raw)
|
|
405
|
-
return []
|
|
408
|
+
return [] if raw.nil? || !raw.is_a?(String) || raw.strip.empty?
|
|
406
409
|
|
|
407
410
|
result = Legion::JSON.load(raw)
|
|
408
411
|
result.is_a?(Array) ? result : []
|
|
@@ -70,11 +70,14 @@ module Legion
|
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
def retrieve_associated(trace_id, min_strength: 0.0, limit: 20)
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
associated = @mutex.synchronize do
|
|
74
|
+
trace = @traces[trace_id]
|
|
75
|
+
next [] unless trace
|
|
75
76
|
|
|
76
|
-
|
|
77
|
-
|
|
77
|
+
trace[:associated_traces].filter_map { |id| @traces[id]&.dup }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
associated
|
|
78
81
|
.select { |t| t[:strength] >= min_strength }
|
|
79
82
|
.sort_by { |t| -t[:strength] }
|
|
80
83
|
.first(limit)
|
|
@@ -354,13 +357,29 @@ module Legion
|
|
|
354
357
|
raw
|
|
355
358
|
end
|
|
356
359
|
|
|
357
|
-
def parse_db_json(raw, field,
|
|
358
|
-
|
|
360
|
+
def parse_db_json(raw, field, symbolize: false, &default)
|
|
361
|
+
return default&.call if raw.nil? || raw.to_s.strip.empty?
|
|
362
|
+
|
|
363
|
+
parsed = Legion::JSON.load(raw.to_s)
|
|
364
|
+
symbolize ? symbolize_keys(parsed) : parsed
|
|
359
365
|
rescue StandardError => e
|
|
360
366
|
log.error "[trace_persistence] deserialize_trace_from_db #{field}: #{e.message}"
|
|
361
367
|
default&.call
|
|
362
368
|
end
|
|
363
369
|
|
|
370
|
+
def symbolize_keys(value)
|
|
371
|
+
case value
|
|
372
|
+
when Hash
|
|
373
|
+
value.each_with_object({}) do |(key, nested), memo|
|
|
374
|
+
memo[key.to_sym] = symbolize_keys(nested)
|
|
375
|
+
end
|
|
376
|
+
when Array
|
|
377
|
+
value.map { |nested| symbolize_keys(nested) }
|
|
378
|
+
else
|
|
379
|
+
value
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
364
383
|
def link_traces(id_a, id_b)
|
|
365
384
|
trace_a = @traces[id_a]
|
|
366
385
|
trace_b = @traces[id_b]
|
|
@@ -556,4 +556,61 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::PostgresStor
|
|
|
556
556
|
expect(store.flush).to be_nil
|
|
557
557
|
end
|
|
558
558
|
end
|
|
559
|
+
|
|
560
|
+
# --- plain-text content round-trip (log spam regression) ---
|
|
561
|
+
|
|
562
|
+
describe 'plain-text content deserialization' do
|
|
563
|
+
it 'returns plain-text content as-is without logging errors' do
|
|
564
|
+
trace = trace_helper.new_trace(type: :episodic, content_payload: 'It appears the service is down.')
|
|
565
|
+
store.store(trace)
|
|
566
|
+
|
|
567
|
+
expect(store).not_to receive(:log)
|
|
568
|
+
result = store.retrieve(trace[:trace_id])
|
|
569
|
+
expect(result[:content]).to eq('It appears the service is down.')
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
it 'parses JSON object content into a hash' do
|
|
573
|
+
trace = trace_helper.new_trace(type: :semantic, content_payload: { fact: 'ruby' })
|
|
574
|
+
store.store(trace)
|
|
575
|
+
|
|
576
|
+
result = store.retrieve(trace[:trace_id])
|
|
577
|
+
expect(result[:content]).to be_a(Hash)
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
it 'does not log errors when domain_tags column is nil' do
|
|
581
|
+
trace = trace_helper.new_trace(type: :episodic, content_payload: 'hello')
|
|
582
|
+
store.store(trace)
|
|
583
|
+
|
|
584
|
+
db[:memory_traces].where(trace_id: trace[:trace_id]).update(domain_tags: nil)
|
|
585
|
+
|
|
586
|
+
expect(store).not_to receive(:log)
|
|
587
|
+
result = store.retrieve(trace[:trace_id])
|
|
588
|
+
expect(result[:domain_tags]).to eq([])
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
it 'does not log errors when associations column is nil' do
|
|
592
|
+
trace = trace_helper.new_trace(type: :episodic, content_payload: 'hello')
|
|
593
|
+
store.store(trace)
|
|
594
|
+
|
|
595
|
+
db[:memory_traces].where(trace_id: trace[:trace_id]).update(associations: nil)
|
|
596
|
+
|
|
597
|
+
expect(store).not_to receive(:log)
|
|
598
|
+
result = store.retrieve(trace[:trace_id])
|
|
599
|
+
expect(result[:associated_traces]).to eq([])
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
it 'generates no ERROR log lines during a bulk read with mixed content types' do
|
|
603
|
+
plain_trace = trace_helper.new_trace(type: :episodic, content_payload: 'Hello, I am plain text')
|
|
604
|
+
json_trace = trace_helper.new_trace(type: :semantic, content_payload: { fact: 'structured' })
|
|
605
|
+
store.store(plain_trace)
|
|
606
|
+
store.store(json_trace)
|
|
607
|
+
|
|
608
|
+
log_double = double('log', debug: nil, info: nil, warn: nil)
|
|
609
|
+
allow(store).to receive(:log).and_return(log_double)
|
|
610
|
+
expect(log_double).not_to receive(:error)
|
|
611
|
+
|
|
612
|
+
results = store.all_traces
|
|
613
|
+
expect(results.size).to eq(2)
|
|
614
|
+
end
|
|
615
|
+
end
|
|
559
616
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'sequel'
|
|
4
|
+
|
|
3
5
|
RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::Store do
|
|
4
6
|
let(:store) { described_class.new }
|
|
5
7
|
let(:trace_helper) { Legion::Extensions::Agentic::Memory::Trace::Helpers::Trace }
|
|
@@ -95,6 +97,18 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::Store do
|
|
|
95
97
|
associated = store.retrieve_associated(semantic_trace[:trace_id])
|
|
96
98
|
expect(associated.size).to eq(0)
|
|
97
99
|
end
|
|
100
|
+
|
|
101
|
+
it 'snapshots associated traces while holding the store mutex' do
|
|
102
|
+
store.store(semantic_trace)
|
|
103
|
+
store.store(episodic_trace)
|
|
104
|
+
semantic_trace[:associated_traces] << episodic_trace[:trace_id]
|
|
105
|
+
|
|
106
|
+
mutex = store.instance_variable_get(:@mutex)
|
|
107
|
+
expect(mutex).to receive(:synchronize).and_call_original
|
|
108
|
+
|
|
109
|
+
associated = store.retrieve_associated(semantic_trace[:trace_id])
|
|
110
|
+
expect(associated.map { |trace| trace[:trace_id] }).to eq([episodic_trace[:trace_id]])
|
|
111
|
+
end
|
|
98
112
|
end
|
|
99
113
|
|
|
100
114
|
describe '#all_traces' do
|
|
@@ -223,6 +237,78 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::Store do
|
|
|
223
237
|
end
|
|
224
238
|
end
|
|
225
239
|
|
|
240
|
+
describe 'parse_db_json nil/empty guard (log spam regression)' do
|
|
241
|
+
let(:local_db) do
|
|
242
|
+
d = Sequel.sqlite
|
|
243
|
+
d.create_table(:memory_traces) do
|
|
244
|
+
primary_key :id
|
|
245
|
+
String :trace_id, size: 36, null: false, unique: true
|
|
246
|
+
String :trace_type, null: false, default: 'episodic'
|
|
247
|
+
String :content, text: true, null: false, default: ''
|
|
248
|
+
Float :strength, default: 1.0
|
|
249
|
+
Float :peak_strength, default: 1.0
|
|
250
|
+
Float :base_decay_rate, default: 0.02
|
|
251
|
+
Float :emotional_valence
|
|
252
|
+
Float :emotional_intensity, default: 0.0
|
|
253
|
+
String :domain_tags, text: true
|
|
254
|
+
String :origin
|
|
255
|
+
String :storage_tier, default: 'hot'
|
|
256
|
+
DateTime :created_at
|
|
257
|
+
DateTime :last_reinforced
|
|
258
|
+
DateTime :last_decayed
|
|
259
|
+
Integer :reinforcement_count, default: 0
|
|
260
|
+
Float :confidence, default: 0.5
|
|
261
|
+
String :partition_id
|
|
262
|
+
String :associated_traces, text: true
|
|
263
|
+
String :parent_id
|
|
264
|
+
String :child_ids, text: true
|
|
265
|
+
TrueClass :unresolved, default: false
|
|
266
|
+
TrueClass :consolidation_candidate, default: false
|
|
267
|
+
end
|
|
268
|
+
d.create_table(:memory_associations) do
|
|
269
|
+
primary_key :id
|
|
270
|
+
String :trace_id_a, size: 36, null: false
|
|
271
|
+
String :trace_id_b, size: 36, null: false
|
|
272
|
+
Integer :coactivation_count, default: 1, null: false
|
|
273
|
+
String :partition_id
|
|
274
|
+
unique %i[trace_id_a trace_id_b]
|
|
275
|
+
end
|
|
276
|
+
d
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
let(:data_local_stub) do
|
|
280
|
+
conn = local_db
|
|
281
|
+
Module.new do
|
|
282
|
+
define_singleton_method(:connected?) { true }
|
|
283
|
+
define_singleton_method(:connection) { conn }
|
|
284
|
+
define_singleton_method(:table_exists?) { |name| conn.table_exists?(name) }
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
before { stub_const('Legion::Data::Local', data_local_stub) }
|
|
289
|
+
|
|
290
|
+
it 'does not log errors when nil optional columns are deserialized on load' do
|
|
291
|
+
local_db[:memory_traces].insert(
|
|
292
|
+
trace_id: SecureRandom.uuid,
|
|
293
|
+
trace_type: 'episodic',
|
|
294
|
+
content: 'plain text content',
|
|
295
|
+
partition_id: 'default',
|
|
296
|
+
strength: 1.0,
|
|
297
|
+
domain_tags: nil,
|
|
298
|
+
associated_traces: nil,
|
|
299
|
+
child_ids: nil,
|
|
300
|
+
emotional_valence: nil
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
fresh = described_class.new
|
|
304
|
+
expect(fresh.count).to eq(1)
|
|
305
|
+
trace = fresh.traces.values.first
|
|
306
|
+
expect(trace[:domain_tags]).to eq([])
|
|
307
|
+
expect(trace[:associated_traces]).to eq([])
|
|
308
|
+
expect(trace[:child_trace_ids]).to eq([])
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
226
312
|
describe '#restore_traces' do
|
|
227
313
|
it 'replaces existing traces and clears stale associations' do
|
|
228
314
|
store.store(semantic_trace)
|
|
@@ -249,6 +249,23 @@ RSpec.describe 'lex-memory local SQLite persistence' do
|
|
|
249
249
|
expect(fresh.get(episodic_trace[:trace_id])).not_to be_nil
|
|
250
250
|
end
|
|
251
251
|
|
|
252
|
+
it 'honors symbolize parsing for JSON hash fields' do
|
|
253
|
+
trace = trace_helper.new_trace(
|
|
254
|
+
type: :semantic,
|
|
255
|
+
content_payload: { fact: 'symbolized' },
|
|
256
|
+
domain_tags: ['json'],
|
|
257
|
+
emotional_valence: { joy: 0.8 }
|
|
258
|
+
)
|
|
259
|
+
store.store(trace)
|
|
260
|
+
store.save_to_local
|
|
261
|
+
|
|
262
|
+
fresh = Legion::Extensions::Agentic::Memory::Trace::Helpers::Store.new
|
|
263
|
+
restored = fresh.get(trace[:trace_id])
|
|
264
|
+
|
|
265
|
+
expect(restored[:emotional_valence]).to include(joy: 0.8)
|
|
266
|
+
expect(restored[:emotional_valence]).not_to have_key('joy')
|
|
267
|
+
end
|
|
268
|
+
|
|
252
269
|
it 'restores associations from the database into a fresh store' do
|
|
253
270
|
store.store(semantic_trace)
|
|
254
271
|
store.store(episodic_trace)
|