lex-agentic-memory 0.1.34 → 0.1.35
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 +5 -0
- data/lib/legion/extensions/agentic/memory/archaeology/helpers/archaeology_engine.rb +3 -3
- data/lib/legion/extensions/agentic/memory/trace/helpers/cache_store.rb +4 -3
- data/lib/legion/extensions/agentic/memory/trace/helpers/postgres_store.rb +7 -6
- data/lib/legion/extensions/agentic/memory/trace/helpers/store.rb +10 -4
- data/lib/legion/extensions/agentic/memory/trace/helpers/trace.rb +86 -2
- data/lib/legion/extensions/agentic/memory/version.rb +1 -1
- data/spec/legion/extensions/agentic/memory/trace/helpers/postgres_store_spec.rb +14 -0
- data/spec/legion/extensions/agentic/memory/trace/helpers/trace_spec.rb +24 -0
- data/spec/legion/extensions/agentic/memory/trace/local_persistence_spec.rb +21 -4
- 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: bb940d62021d4a86ed5de171d6b275c9287a9ca727efadd3c60c3a87ca0f921e
|
|
4
|
+
data.tar.gz: b02842d4a5939e436d7ae4f72cca295839061f5fa4cdbf9b47736f37e462e76e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e28e286f59952562c8c00ad37cb06f84914f2b93af864cb95a1d9904c218dc72d28bb09a4df049c418f5a66df752a199171c0e553e684c234ae888ab73be24a8
|
|
7
|
+
data.tar.gz: 12febef87343cde5c4ca8f372c62a6f7d5b053a47ad5362710ee54224be60ceb899f7f0f32eeb65cc9474ebe5b47535731e2f6a3e83a89f3d315c6780774ea77
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
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
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
7
|
|
|
8
|
+
## [0.1.35] - 2026-05-15
|
|
9
|
+
### Fixed
|
|
10
|
+
- Normalize trace affect fields before persisting through in-memory, cache, local SQLite, and Postgres stores so string-backed values do not crash or silently collapse to `0.0`.
|
|
11
|
+
- Preserve numeric `emotional_valence` across local trace persistence and normalize legacy structured payloads when they are reloaded.
|
|
12
|
+
|
|
8
13
|
## [0.1.33] - 2026-05-07
|
|
9
14
|
### Fixed
|
|
10
15
|
- `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
|
|
@@ -164,9 +164,9 @@ module Legion
|
|
|
164
164
|
site.survey.merge(
|
|
165
165
|
depth_progress: depth_progress(site),
|
|
166
166
|
artifact_types: site.artifacts_found
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
167
|
+
.each_with_object(Hash.new(0)) do |a, h|
|
|
168
|
+
h[a.artifact_type] += 1
|
|
169
|
+
end
|
|
170
170
|
)
|
|
171
171
|
end
|
|
172
172
|
|
|
@@ -38,11 +38,12 @@ module Legion
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def store(trace)
|
|
41
|
+
normalized_trace = Helpers::Trace.normalize_trace_affect(trace)
|
|
41
42
|
@mutex.synchronize do
|
|
42
|
-
@traces[
|
|
43
|
-
@dirty_ids <<
|
|
43
|
+
@traces[normalized_trace[:trace_id]] = normalized_trace
|
|
44
|
+
@dirty_ids << normalized_trace[:trace_id]
|
|
44
45
|
end
|
|
45
|
-
|
|
46
|
+
normalized_trace[:trace_id]
|
|
46
47
|
end
|
|
47
48
|
|
|
48
49
|
def get(trace_id)
|
|
@@ -27,15 +27,16 @@ module Legion
|
|
|
27
27
|
def store(trace)
|
|
28
28
|
return nil unless db_ready?
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
normalized_trace = Helpers::Trace.normalize_trace_affect(trace)
|
|
31
|
+
row = serialize_trace(normalized_trace)
|
|
31
32
|
ds = db[TRACES_TABLE]
|
|
32
33
|
if db.adapter_scheme == :mysql2
|
|
33
34
|
ds.insert_conflict(update: row.except(:trace_id)).insert(row)
|
|
34
35
|
else
|
|
35
36
|
ds.insert_conflict(target: :trace_id, update: row.except(:trace_id)).insert(row)
|
|
36
37
|
end
|
|
37
|
-
HotTier.cache_trace(
|
|
38
|
-
|
|
38
|
+
HotTier.cache_trace(normalized_trace, tenant_id: @tenant_id, agent_id: @agent_id) if HotTier.available?
|
|
39
|
+
normalized_trace[:trace_id]
|
|
39
40
|
rescue StandardError => e
|
|
40
41
|
log_warn("store failed: #{e.message}")
|
|
41
42
|
nil
|
|
@@ -299,7 +300,7 @@ module Legion
|
|
|
299
300
|
tags = trace[:domain_tags]
|
|
300
301
|
assocs = trace[:associated_traces]
|
|
301
302
|
conf = trace[:confidence]
|
|
302
|
-
ev = trace[:emotional_valence]
|
|
303
|
+
ev = Helpers::Trace.normalize_emotional_valence(trace[:emotional_valence])
|
|
303
304
|
|
|
304
305
|
{
|
|
305
306
|
trace_id: trace[:trace_id],
|
|
@@ -314,8 +315,8 @@ module Legion
|
|
|
314
315
|
strength: trace[:strength],
|
|
315
316
|
peak_strength: trace[:peak_strength],
|
|
316
317
|
base_decay_rate: trace[:base_decay_rate],
|
|
317
|
-
emotional_valence: ev
|
|
318
|
-
emotional_intensity: trace[:emotional_intensity],
|
|
318
|
+
emotional_valence: ev,
|
|
319
|
+
emotional_intensity: Helpers::Trace.normalize_emotional_intensity(trace[:emotional_intensity]),
|
|
319
320
|
origin: sanitize_pg_string(trace[:origin].to_s),
|
|
320
321
|
source_agent_id: sanitize_pg_string(trace[:source_agent_id]),
|
|
321
322
|
storage_tier: sanitize_pg_string(trace[:storage_tier].to_s),
|
|
@@ -29,7 +29,7 @@ module Legion
|
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
def store(trace)
|
|
32
|
-
persisted_trace = trace
|
|
32
|
+
persisted_trace = Helpers::Trace.normalize_trace_affect(trace)
|
|
33
33
|
persisted_trace[:partition_id] ||= @partition_id
|
|
34
34
|
@mutex.synchronize do
|
|
35
35
|
@traces_dirty = true if @traces[persisted_trace[:trace_id]] != persisted_trace
|
|
@@ -120,7 +120,7 @@ module Legion
|
|
|
120
120
|
snapshot = Array(traces).each_with_object({}) do |trace, memo|
|
|
121
121
|
next unless trace.is_a?(Hash) && trace[:trace_id]
|
|
122
122
|
|
|
123
|
-
restored = trace
|
|
123
|
+
restored = Helpers::Trace.normalize_trace_affect(trace)
|
|
124
124
|
restored[:partition_id] ||= @partition_id
|
|
125
125
|
memo[restored[:trace_id]] = restored
|
|
126
126
|
end
|
|
@@ -294,7 +294,7 @@ module Legion
|
|
|
294
294
|
strength: trace[:strength],
|
|
295
295
|
peak_strength: trace[:peak_strength],
|
|
296
296
|
base_decay_rate: trace[:base_decay_rate],
|
|
297
|
-
emotional_valence:
|
|
297
|
+
emotional_valence: Helpers::Trace.normalize_emotional_valence(trace[:emotional_valence]).to_s,
|
|
298
298
|
emotional_intensity: trace[:emotional_intensity],
|
|
299
299
|
domain_tags: trace[:domain_tags].is_a?(Array) ? ::JSON.generate(trace[:domain_tags]) : nil,
|
|
300
300
|
origin: trace[:origin].to_s,
|
|
@@ -325,7 +325,7 @@ module Legion
|
|
|
325
325
|
strength: row[:strength],
|
|
326
326
|
peak_strength: row[:peak_strength],
|
|
327
327
|
base_decay_rate: row[:base_decay_rate],
|
|
328
|
-
emotional_valence:
|
|
328
|
+
emotional_valence: deserialize_emotional_valence(row[:emotional_valence]),
|
|
329
329
|
emotional_intensity: row[:emotional_intensity],
|
|
330
330
|
domain_tags: parse_db_json(row[:domain_tags], 'domain_tags') { [] },
|
|
331
331
|
origin: row[:origin]&.to_sym,
|
|
@@ -364,6 +364,12 @@ module Legion
|
|
|
364
364
|
%w[{ [ " ]].include?(first_array_value)
|
|
365
365
|
end
|
|
366
366
|
|
|
367
|
+
def deserialize_emotional_valence(raw)
|
|
368
|
+
return 0.0 if raw.nil? || raw.to_s.strip.empty?
|
|
369
|
+
|
|
370
|
+
Helpers::Trace.normalize_emotional_valence(raw)
|
|
371
|
+
end
|
|
372
|
+
|
|
367
373
|
def parse_db_json(raw, field, symbolize: false, &default)
|
|
368
374
|
return default&.call if raw.nil? || raw.to_s.strip.empty?
|
|
369
375
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
3
4
|
require 'securerandom'
|
|
4
5
|
|
|
5
6
|
module Legion
|
|
@@ -48,6 +49,7 @@ module Legion
|
|
|
48
49
|
ASSOCIATION_BONUS = 0.15 # bonus for Hebbian-associated traces
|
|
49
50
|
MAX_ASSOCIATIONS = 20 # max Hebbian links per trace
|
|
50
51
|
COACTIVATION_THRESHOLD = 3 # co-activations before Hebbian link forms
|
|
52
|
+
VALENCE_SCALAR_KEYS = %i[valence emotional_valence sentiment polarity score].freeze
|
|
51
53
|
|
|
52
54
|
module_function
|
|
53
55
|
|
|
@@ -59,6 +61,10 @@ module Legion
|
|
|
59
61
|
raise ArgumentError, "invalid origin: #{origin}" unless ORIGINS.include?(origin)
|
|
60
62
|
|
|
61
63
|
now = Time.now.utc
|
|
64
|
+
emotional_context = normalize_trace_affect(
|
|
65
|
+
emotional_valence: emotional_valence,
|
|
66
|
+
emotional_intensity: emotional_intensity
|
|
67
|
+
)
|
|
62
68
|
|
|
63
69
|
{
|
|
64
70
|
trace_id: SecureRandom.uuid,
|
|
@@ -68,8 +74,8 @@ module Legion
|
|
|
68
74
|
strength: STARTING_STRENGTHS[type],
|
|
69
75
|
peak_strength: STARTING_STRENGTHS[type],
|
|
70
76
|
base_decay_rate: BASE_DECAY_RATES[type],
|
|
71
|
-
emotional_valence: emotional_valence
|
|
72
|
-
emotional_intensity: emotional_intensity
|
|
77
|
+
emotional_valence: emotional_context[:emotional_valence],
|
|
78
|
+
emotional_intensity: emotional_context[:emotional_intensity],
|
|
73
79
|
domain_tags: Array(domain_tags),
|
|
74
80
|
origin: origin,
|
|
75
81
|
source_agent_id: source_agent_id,
|
|
@@ -98,11 +104,89 @@ module Legion
|
|
|
98
104
|
true
|
|
99
105
|
end
|
|
100
106
|
|
|
107
|
+
def normalize_trace_affect(trace)
|
|
108
|
+
normalized = trace.dup
|
|
109
|
+
normalized[:emotional_valence] = normalize_emotional_valence(normalized[:emotional_valence])
|
|
110
|
+
normalized[:emotional_intensity] = normalize_emotional_intensity(normalized[:emotional_intensity])
|
|
111
|
+
normalized
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def normalize_emotional_valence(value)
|
|
115
|
+
normalize_scalar(value, min: -1.0, max: 1.0, hash_keys: VALENCE_SCALAR_KEYS)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def normalize_emotional_intensity(value)
|
|
119
|
+
normalize_scalar(value, min: 0.0, max: 1.0)
|
|
120
|
+
end
|
|
121
|
+
|
|
101
122
|
def default_partition_id
|
|
102
123
|
Legion::Settings.dig(:agent, :id) || 'default'
|
|
103
124
|
rescue StandardError => _e
|
|
104
125
|
'default'
|
|
105
126
|
end
|
|
127
|
+
|
|
128
|
+
def normalize_scalar(value, min:, max:, hash_keys: [])
|
|
129
|
+
case value
|
|
130
|
+
when Numeric
|
|
131
|
+
value.to_f.clamp(min, max)
|
|
132
|
+
when String
|
|
133
|
+
normalize_string_scalar(value, min: min, max: max, hash_keys: hash_keys)
|
|
134
|
+
when Hash
|
|
135
|
+
normalize_hash_scalar(value, min: min, max: max, hash_keys: hash_keys)
|
|
136
|
+
else
|
|
137
|
+
0.0
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def normalize_string_scalar(value, min:, max:, hash_keys:)
|
|
142
|
+
stripped = value.strip
|
|
143
|
+
return 0.0 if stripped.empty?
|
|
144
|
+
|
|
145
|
+
Float(stripped).clamp(min, max)
|
|
146
|
+
rescue ArgumentError, TypeError => e
|
|
147
|
+
Legion::Logging.debug("[memory][trace] normalize_string_scalar fallback: #{e.message}")
|
|
148
|
+
parsed = parse_structured_scalar(stripped)
|
|
149
|
+
return normalize_scalar(parsed, min: min, max: max, hash_keys: hash_keys) if parsed
|
|
150
|
+
|
|
151
|
+
0.0
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def normalize_hash_scalar(value, min:, max:, hash_keys:)
|
|
155
|
+
symbolized = symbolize_keys(value)
|
|
156
|
+
scalar_value = hash_keys.lazy.map { |key| symbolized[key] }.find { |candidate| scalar_candidate?(candidate) }
|
|
157
|
+
return normalize_scalar(scalar_value, min: min, max: max, hash_keys: hash_keys) if scalar_candidate?(scalar_value)
|
|
158
|
+
|
|
159
|
+
numeric_values = symbolized.values.select { |candidate| scalar_candidate?(candidate) }
|
|
160
|
+
return normalize_scalar(numeric_values.first, min: min, max: max, hash_keys: hash_keys) if numeric_values.one?
|
|
161
|
+
|
|
162
|
+
0.0
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def parse_structured_scalar(value)
|
|
166
|
+
return unless value.start_with?('{', '[')
|
|
167
|
+
|
|
168
|
+
::JSON.parse(value)
|
|
169
|
+
rescue ::JSON::ParserError => e
|
|
170
|
+
Legion::Logging.debug("[memory][trace] parse_structured_scalar ignored: #{e.message}")
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def scalar_candidate?(value)
|
|
175
|
+
value.is_a?(Numeric) || value.is_a?(String)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def symbolize_keys(value)
|
|
179
|
+
case value
|
|
180
|
+
when Hash
|
|
181
|
+
value.each_with_object({}) do |(key, nested), memo|
|
|
182
|
+
memo[key.to_sym] = symbolize_keys(nested)
|
|
183
|
+
end
|
|
184
|
+
when Array
|
|
185
|
+
value.map { |nested| symbolize_keys(nested) }
|
|
186
|
+
else
|
|
187
|
+
value
|
|
188
|
+
end
|
|
189
|
+
end
|
|
106
190
|
end
|
|
107
191
|
end
|
|
108
192
|
end
|
|
@@ -238,6 +238,20 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::PostgresStor
|
|
|
238
238
|
end
|
|
239
239
|
end
|
|
240
240
|
|
|
241
|
+
describe 'emotional valence normalization' do
|
|
242
|
+
it 'normalizes string-backed affect fields before persisting' do
|
|
243
|
+
trace = trace_helper.new_trace(type: :episodic, content_payload: { event: 'partner ping' })
|
|
244
|
+
trace[:emotional_valence] = '0.7'
|
|
245
|
+
trace[:emotional_intensity] = '0.9'
|
|
246
|
+
|
|
247
|
+
store.store(trace)
|
|
248
|
+
|
|
249
|
+
row = db[:memory_traces].where(trace_id: trace[:trace_id]).first
|
|
250
|
+
expect(row[:emotional_valence]).to be_within(0.001).of(0.7)
|
|
251
|
+
expect(row[:emotional_intensity]).to be_within(0.001).of(0.9)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
241
255
|
# --- retrieve_by_type ---
|
|
242
256
|
|
|
243
257
|
describe '#retrieve_by_type' do
|
|
@@ -36,6 +36,30 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::Trace do
|
|
|
36
36
|
expect(trace[:emotional_intensity]).to eq(0.0)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
it 'normalizes string and structured emotional values safely' do
|
|
40
|
+
trace = described_class.new_trace(
|
|
41
|
+
type: :episodic,
|
|
42
|
+
content_payload: { event: 'partner ping' },
|
|
43
|
+
emotional_valence: '{:urgency=>0.6, :importance=>0.8}',
|
|
44
|
+
emotional_intensity: '0.75'
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
expect(trace[:emotional_valence]).to eq(0.0)
|
|
48
|
+
expect(trace[:emotional_intensity]).to eq(0.75)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'extracts scalar valence from hash payloads when an explicit valence key exists' do
|
|
52
|
+
trace = described_class.new_trace(
|
|
53
|
+
type: :semantic,
|
|
54
|
+
content_payload: { fact: 'test' },
|
|
55
|
+
emotional_valence: { valence: 0.4 },
|
|
56
|
+
emotional_intensity: '2.0'
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
expect(trace[:emotional_valence]).to eq(0.4)
|
|
60
|
+
expect(trace[:emotional_intensity]).to eq(1.0)
|
|
61
|
+
end
|
|
62
|
+
|
|
39
63
|
it 'rejects invalid trace types' do
|
|
40
64
|
expect do
|
|
41
65
|
described_class.new_trace(type: :bogus, content_payload: {})
|
|
@@ -249,12 +249,12 @@ 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 '
|
|
252
|
+
it 'restores numeric emotional_valence values from local persistence' do
|
|
253
253
|
trace = trace_helper.new_trace(
|
|
254
254
|
type: :semantic,
|
|
255
255
|
content_payload: { fact: 'symbolized' },
|
|
256
256
|
domain_tags: ['json'],
|
|
257
|
-
emotional_valence:
|
|
257
|
+
emotional_valence: 0.8
|
|
258
258
|
)
|
|
259
259
|
store.store(trace)
|
|
260
260
|
store.save_to_local
|
|
@@ -262,8 +262,25 @@ RSpec.describe 'lex-memory local SQLite persistence' do
|
|
|
262
262
|
fresh = Legion::Extensions::Agentic::Memory::Trace::Helpers::Store.new
|
|
263
263
|
restored = fresh.get(trace[:trace_id])
|
|
264
264
|
|
|
265
|
-
expect(restored[:emotional_valence]).to
|
|
266
|
-
|
|
265
|
+
expect(restored[:emotional_valence]).to be_within(0.001).of(0.8)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
it 'normalizes legacy JSON emotional_valence payloads on load' do
|
|
269
|
+
trace = trace_helper.new_trace(
|
|
270
|
+
type: :semantic,
|
|
271
|
+
content_payload: { fact: 'legacy' },
|
|
272
|
+
domain_tags: ['json']
|
|
273
|
+
)
|
|
274
|
+
store.store(trace)
|
|
275
|
+
store.save_to_local
|
|
276
|
+
|
|
277
|
+
db = Legion::Data::Local.connection
|
|
278
|
+
db[:memory_traces].where(trace_id: trace[:trace_id]).update(emotional_valence: '{"valence":0.8}')
|
|
279
|
+
|
|
280
|
+
fresh = Legion::Extensions::Agentic::Memory::Trace::Helpers::Store.new
|
|
281
|
+
restored = fresh.get(trace[:trace_id])
|
|
282
|
+
|
|
283
|
+
expect(restored[:emotional_valence]).to be_within(0.001).of(0.8)
|
|
267
284
|
end
|
|
268
285
|
|
|
269
286
|
it 'restores associations from the database into a fresh store' do
|