lex-agentic-memory 0.1.32 → 0.1.34
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/client.rb +4 -2
- data/lib/legion/extensions/agentic/memory/trace/helpers/postgres_store.rb +6 -3
- data/lib/legion/extensions/agentic/memory/trace/helpers/store.rb +12 -3
- data/lib/legion/extensions/agentic/memory/trace/runners/consolidation.rb +7 -3
- data/lib/legion/extensions/agentic/memory/version.rb +1 -1
- data/spec/legion/extensions/agentic/memory/trace/client_spec.rb +8 -0
- 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 +98 -0
- data/spec/legion/extensions/agentic/memory/trace/runners/consolidation_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: 606b711547732f1f697b57ce2392509c2774397c725b51f87baeaee591cf2ae2
|
|
4
|
+
data.tar.gz: f9f704beca9f4f33c8d71ad54627ef400561819898aeebf06c0097a0c0a535b1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 35f855a01efbfb5b29f23fe12f92e89082a3a4c8001b149dd16f6ccd648bc9d67a699755230e5b08c4a86f1147522f86b71d5fea3e31f3842448c20e4af3d094
|
|
7
|
+
data.tar.gz: 8e9afdf79c62437fefe856a61a0432d1574a7fba7dadab51c4a6b19a0e8a6d6a3658a19fbfa725d5cdb5c4c8de300169818aeee15c1e204e9cf7ff15a9cf12d3
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.34] - 2026-05-08
|
|
4
|
+
### Fixed
|
|
5
|
+
- Deferred GAIA heartbeat maintenance no longer initializes the shared trace store just to return a deferred summary, avoiding idle local trace table scans.
|
|
6
|
+
- `Store#parse_db_content` now returns bracket-prefixed raw trace/log text without JSON parse attempts or malformed JSON debug noise while still parsing real JSON objects and arrays.
|
|
7
|
+
|
|
8
|
+
## [0.1.33] - 2026-05-07
|
|
9
|
+
### Fixed
|
|
10
|
+
- `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
|
|
11
|
+
- `PostgresStore#parse_json_array` now guards against nil/empty strings before parsing
|
|
12
|
+
- `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
|
|
13
|
+
|
|
3
14
|
## [0.1.32] - 2026-05-07
|
|
4
15
|
### Fixed
|
|
5
16
|
- Trace association retrieval now snapshots associated traces under the store mutex before filtering.
|
|
@@ -18,12 +18,14 @@ module Legion
|
|
|
18
18
|
attr_reader :store
|
|
19
19
|
|
|
20
20
|
def initialize(store: nil, **)
|
|
21
|
-
@default_store = store
|
|
21
|
+
@default_store = store if store
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
private
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
def default_store
|
|
27
|
+
@default_store ||= Legion::Extensions::Agentic::Memory::Trace.shared_store
|
|
28
|
+
end
|
|
27
29
|
end
|
|
28
30
|
end
|
|
29
31
|
end
|
|
@@ -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 : []
|
|
@@ -348,16 +348,25 @@ module Legion
|
|
|
348
348
|
return raw unless raw.is_a?(String)
|
|
349
349
|
|
|
350
350
|
stripped = raw.strip
|
|
351
|
-
return raw unless
|
|
351
|
+
return raw unless parseable_content_json?(stripped)
|
|
352
352
|
|
|
353
353
|
parsed = Legion::JSON.load(stripped)
|
|
354
354
|
parsed.is_a?(Hash) || parsed.is_a?(Array) ? parsed : raw
|
|
355
|
-
rescue StandardError =>
|
|
356
|
-
log.debug "[trace_persistence] malformed JSON in content column, returning raw: #{e.message}"
|
|
355
|
+
rescue StandardError => _e
|
|
357
356
|
raw
|
|
358
357
|
end
|
|
359
358
|
|
|
359
|
+
def parseable_content_json?(value)
|
|
360
|
+
return true if value.start_with?('{')
|
|
361
|
+
return false unless value.start_with?('[')
|
|
362
|
+
|
|
363
|
+
first_array_value = value[1..]&.lstrip&.[](0)
|
|
364
|
+
%w[{ [ " ]].include?(first_array_value)
|
|
365
|
+
end
|
|
366
|
+
|
|
360
367
|
def parse_db_json(raw, field, symbolize: false, &default)
|
|
368
|
+
return default&.call if raw.nil? || raw.to_s.strip.empty?
|
|
369
|
+
|
|
361
370
|
parsed = Legion::JSON.load(raw.to_s)
|
|
362
371
|
symbolize ? symbolize_keys(parsed) : parsed
|
|
363
372
|
rescue StandardError => e
|
|
@@ -31,12 +31,12 @@ module Legion
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def decay_cycle(store: nil, tick_count: 1, maintenance: true, **)
|
|
34
|
-
store ||= default_store
|
|
35
34
|
unless maintenance
|
|
36
|
-
deferred = deferred_decay_summary(store)
|
|
35
|
+
deferred = deferred_decay_summary(store || cached_default_store)
|
|
37
36
|
return { **deferred }
|
|
38
37
|
end
|
|
39
38
|
|
|
39
|
+
store ||= default_store
|
|
40
40
|
decayed = 0
|
|
41
41
|
pruned = 0
|
|
42
42
|
total = trace_count(store)
|
|
@@ -139,7 +139,7 @@ module Legion
|
|
|
139
139
|
|
|
140
140
|
def deferred_decay_summary(store)
|
|
141
141
|
summary = Legion::Extensions::Agentic::Memory::Trace.last_maintenance_summary || {}
|
|
142
|
-
current_count = trace_count(store)
|
|
142
|
+
current_count = store ? trace_count(store) : 0
|
|
143
143
|
|
|
144
144
|
{
|
|
145
145
|
decayed: summary[:decayed] || 0,
|
|
@@ -173,6 +173,10 @@ module Legion
|
|
|
173
173
|
@default_store ||= Legion::Extensions::Agentic::Memory::Trace.shared_store
|
|
174
174
|
end
|
|
175
175
|
|
|
176
|
+
def cached_default_store
|
|
177
|
+
@default_store if instance_variable_defined?(:@default_store)
|
|
178
|
+
end
|
|
179
|
+
|
|
176
180
|
include Legion::Extensions::Helpers::Lex if defined?(Legion::Extensions::Helpers::Lex)
|
|
177
181
|
end
|
|
178
182
|
end
|
|
@@ -24,6 +24,14 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Client do
|
|
|
24
24
|
expect(client).to respond_to(:erase_by_agent)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
it 'does not initialize the shared trace store until a store-backed operation needs it' do
|
|
28
|
+
allow(Legion::Extensions::Agentic::Memory::Trace).to receive(:shared_store)
|
|
29
|
+
|
|
30
|
+
described_class.new
|
|
31
|
+
|
|
32
|
+
expect(Legion::Extensions::Agentic::Memory::Trace).not_to have_received(:shared_store)
|
|
33
|
+
end
|
|
34
|
+
|
|
27
35
|
it 'uses provided store' do
|
|
28
36
|
store = Legion::Extensions::Agentic::Memory::Trace::Helpers::Store.new
|
|
29
37
|
client = described_class.new(store: store)
|
|
@@ -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 }
|
|
@@ -126,6 +128,30 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::Store do
|
|
|
126
128
|
end
|
|
127
129
|
end
|
|
128
130
|
|
|
131
|
+
describe '#parse_db_content' do
|
|
132
|
+
let(:parser) { described_class.allocate }
|
|
133
|
+
|
|
134
|
+
it 'returns bracket-prefixed raw log text without logging malformed JSON noise' do
|
|
135
|
+
expect(parser).not_to receive(:log)
|
|
136
|
+
|
|
137
|
+
result = parser.send(:parse_db_content, '[tool][file_read] opened file')
|
|
138
|
+
|
|
139
|
+
expect(result).to eq('[tool][file_read] opened file')
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it 'still parses JSON object content' do
|
|
143
|
+
result = parser.send(:parse_db_content, '{"event":"meeting"}')
|
|
144
|
+
|
|
145
|
+
expect(result).to eq({ event: 'meeting' })
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it 'still parses JSON array content when it looks like serialized payload data' do
|
|
149
|
+
result = parser.send(:parse_db_content, '[{"event":"meeting"}]')
|
|
150
|
+
|
|
151
|
+
expect(result).to eq([{ event: 'meeting' }])
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
129
155
|
describe '#count' do
|
|
130
156
|
it 'returns number of stored traces' do
|
|
131
157
|
expect(store.count).to eq(0)
|
|
@@ -235,6 +261,78 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::Store do
|
|
|
235
261
|
end
|
|
236
262
|
end
|
|
237
263
|
|
|
264
|
+
describe 'parse_db_json nil/empty guard (log spam regression)' do
|
|
265
|
+
let(:local_db) do
|
|
266
|
+
d = Sequel.sqlite
|
|
267
|
+
d.create_table(:memory_traces) do
|
|
268
|
+
primary_key :id
|
|
269
|
+
String :trace_id, size: 36, null: false, unique: true
|
|
270
|
+
String :trace_type, null: false, default: 'episodic'
|
|
271
|
+
String :content, text: true, null: false, default: ''
|
|
272
|
+
Float :strength, default: 1.0
|
|
273
|
+
Float :peak_strength, default: 1.0
|
|
274
|
+
Float :base_decay_rate, default: 0.02
|
|
275
|
+
Float :emotional_valence
|
|
276
|
+
Float :emotional_intensity, default: 0.0
|
|
277
|
+
String :domain_tags, text: true
|
|
278
|
+
String :origin
|
|
279
|
+
String :storage_tier, default: 'hot'
|
|
280
|
+
DateTime :created_at
|
|
281
|
+
DateTime :last_reinforced
|
|
282
|
+
DateTime :last_decayed
|
|
283
|
+
Integer :reinforcement_count, default: 0
|
|
284
|
+
Float :confidence, default: 0.5
|
|
285
|
+
String :partition_id
|
|
286
|
+
String :associated_traces, text: true
|
|
287
|
+
String :parent_id
|
|
288
|
+
String :child_ids, text: true
|
|
289
|
+
TrueClass :unresolved, default: false
|
|
290
|
+
TrueClass :consolidation_candidate, default: false
|
|
291
|
+
end
|
|
292
|
+
d.create_table(:memory_associations) do
|
|
293
|
+
primary_key :id
|
|
294
|
+
String :trace_id_a, size: 36, null: false
|
|
295
|
+
String :trace_id_b, size: 36, null: false
|
|
296
|
+
Integer :coactivation_count, default: 1, null: false
|
|
297
|
+
String :partition_id
|
|
298
|
+
unique %i[trace_id_a trace_id_b]
|
|
299
|
+
end
|
|
300
|
+
d
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
let(:data_local_stub) do
|
|
304
|
+
conn = local_db
|
|
305
|
+
Module.new do
|
|
306
|
+
define_singleton_method(:connected?) { true }
|
|
307
|
+
define_singleton_method(:connection) { conn }
|
|
308
|
+
define_singleton_method(:table_exists?) { |name| conn.table_exists?(name) }
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
before { stub_const('Legion::Data::Local', data_local_stub) }
|
|
313
|
+
|
|
314
|
+
it 'does not log errors when nil optional columns are deserialized on load' do
|
|
315
|
+
local_db[:memory_traces].insert(
|
|
316
|
+
trace_id: SecureRandom.uuid,
|
|
317
|
+
trace_type: 'episodic',
|
|
318
|
+
content: 'plain text content',
|
|
319
|
+
partition_id: 'default',
|
|
320
|
+
strength: 1.0,
|
|
321
|
+
domain_tags: nil,
|
|
322
|
+
associated_traces: nil,
|
|
323
|
+
child_ids: nil,
|
|
324
|
+
emotional_valence: nil
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
fresh = described_class.new
|
|
328
|
+
expect(fresh.count).to eq(1)
|
|
329
|
+
trace = fresh.traces.values.first
|
|
330
|
+
expect(trace[:domain_tags]).to eq([])
|
|
331
|
+
expect(trace[:associated_traces]).to eq([])
|
|
332
|
+
expect(trace[:child_trace_ids]).to eq([])
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
238
336
|
describe '#restore_traces' do
|
|
239
337
|
it 'replaces existing traces and clears stale associations' do
|
|
240
338
|
store.store(semantic_trace)
|
|
@@ -53,6 +53,23 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Runners::Consolidatio
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
describe '#decay_cycle' do
|
|
56
|
+
it 'does not initialize the shared store when Gaia defers heartbeat maintenance' do
|
|
57
|
+
runner = Object.new.extend(described_class)
|
|
58
|
+
allow(Legion::Extensions::Agentic::Memory::Trace).to receive(:shared_store)
|
|
59
|
+
|
|
60
|
+
result = runner.decay_cycle(maintenance: false)
|
|
61
|
+
|
|
62
|
+
expect(Legion::Extensions::Agentic::Memory::Trace).not_to have_received(:shared_store)
|
|
63
|
+
expect(result).to include(
|
|
64
|
+
decayed: 0,
|
|
65
|
+
pruned: 0,
|
|
66
|
+
total: 0,
|
|
67
|
+
remaining: 0,
|
|
68
|
+
deferred: true,
|
|
69
|
+
reason: :background_decay_actor
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
56
73
|
it 'defers Gaia heartbeat decay work to the background actor when maintenance is false' do
|
|
57
74
|
client.store_trace(type: :semantic, content_payload: {})
|
|
58
75
|
|