lex-agentic-memory 0.1.38 → 0.1.39

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 69de94b29aa73fa12a7adb17e3c114d5d01dd6cfd89f076d77ed053ee1c0e663
4
- data.tar.gz: dc9a817d778c429e27b1225daf790f5aaf2c87c2ad11ce3237b34c24730717ce
3
+ metadata.gz: 89cf392450f3ff2b43d9ff1430c9cb4e8b108d0221b46aa4c2e3e064514c6200
4
+ data.tar.gz: 0343f1b89ae601f477939c5890fca5bcb2e8e4961f411049612731edf16ae43e
5
5
  SHA512:
6
- metadata.gz: 68a898cb81e1e16dfbe001a150fd1c5254eac34dedd7477d17c13d360c19117ac4e8d82a402538b44d3d6f7f67bd67d6f87d4fc41d71005010c63d6cc7e58506
7
- data.tar.gz: a52951edc85a09cb417eefafd057acd7be4ab723ced5b0a0fd65c0c89b8a9e6af5f1eebd9e5bdcc62b9154c421d311d3c0d1d4ec05d5934fcab5486eab21c928
6
+ metadata.gz: ca0b83053d099fd5e445427105023210371b4e4048961bfa4d3446b8ca1412902c8c6dbbcfaa0881d787d55ba5ec70fc5321023374c3a9c35b6718d99e9af8b8
7
+ data.tar.gz: 5c6e4acf09c5cafae4694253381e963316fe898e9c738bb9ae0143e9788e242e01b5dd442f3187ebb8f997b1a3ef095fdb1c1fd9a77c0ac2072c0e255247b860
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.39] - 2026-06-01
4
+ ### Fixed
5
+ - `ErrorTracer` now guards against infinite recursion — if downstream trace storage triggers error/fatal logging, the `tracing?` thread-local flag prevents re-entry
6
+ - `ErrorTracer` background worker thread now terminates cleanly — `setup` registers an `at_exit` hook, and a new `shutdown` method pushes the `:stop` sentinel to the queue (previously the thread ran forever with no shutdown path)
7
+ - `HotTier.serialize_trace` now preserves all trace fields (was only serializing 9 out of 25+) — adds `base_decay_rate`, `emotional_valence`, `emotional_intensity`, `origin`, `source_agent_id`, `domain_tags`, `associated_traces`, `child_trace_ids`, `reinforcement_count`, `unresolved`, `consolidation_candidate`, `last_decayed`, `created_at`, `encryption_key_id`, `parent_trace_id`
8
+ - `HotTier.deserialize_trace` reconstructs all fields with proper type coercion (arrays via JSON, booleans, integers, symbols, timestamps)
9
+ - `PostgresStore#deserialize_trace` now reads `child_trace_ids` from the DB column instead of hardcoding `[]` — children are no longer silently lost on retrieval
10
+ - `PostgresStore#serialize_trace` now writes `child_trace_ids` to the DB row
11
+ - `PostgresStore#map_update_fields` now maps `child_trace_ids` to the `:child_trace_ids` column (was `nil`, silently dropping partial updates)
12
+ - `PostgresStore#retrieve_by_domain` uses PostgreSQL JSONB `@>` containment operator for exact array-member matching instead of `LIKE` — eliminates full table scans and substring false positives (e.g., `%auth%` matching `oauth`)
13
+ - `Consolidation#erase_by_type` now uses `batch_delete_by_type` on PostgresStore, avoiding loading all matching traces into Ruby memory before deleting one-by-one (O(100k) heap allocation → O(1) SQL delete)
14
+
3
15
  ## [0.1.38] - 2026-05-27
4
16
  ### Fixed
5
17
  - `PostgresStore#parse_json_or_raw` now requires matching start/end delimiters (`{}`/`[]`) before attempting JSON parse — eliminates 100k+/day error log spam from bracket-prefixed text content (e.g. `[trace_persistence] ...`) that triggered a self-amplifying feedback loop via RabbitMQ logging
@@ -161,12 +161,13 @@ module Legion
161
161
  end
162
162
 
163
163
  def site_summary(site)
164
+ artifact_types = site.artifacts_found.each_with_object(Hash.new(0)) do |a, h|
165
+ h[a.artifact_type] += 1
166
+ end
167
+
164
168
  site.survey.merge(
165
169
  depth_progress: depth_progress(site),
166
- artifact_types: site.artifacts_found
167
- .each_with_object(Hash.new(0)) do |a, h|
168
- h[a.artifact_type] += 1
169
- end
170
+ artifact_types: artifact_types
170
171
  )
171
172
  end
172
173
 
@@ -25,6 +25,7 @@ module Legion
25
25
  @worker.name = 'legion-error-tracer'
26
26
  wrap_logging_methods
27
27
  @active = true
28
+ register_shutdown_hook
28
29
  Legion::Logging.info '[memory] ErrorTracer active — errors/fatals will become episodic traces'
29
30
  end
30
31
 
@@ -32,8 +33,25 @@ module Legion
32
33
  @active == true
33
34
  end
34
35
 
36
+ # Gracefully shut down the background worker and unregister the at_exit hook.
37
+ def shutdown
38
+ return unless @active
39
+
40
+ @write_queue&.push(:stop)
41
+ @worker&.join(5) # wait up to 5 seconds for clean exit
42
+ @active = false
43
+ Legion::Logging.info '[memory] ErrorTracer shut down'
44
+ rescue StandardError
45
+ @active = false
46
+ end
47
+
35
48
  private
36
49
 
50
+ def register_shutdown_hook
51
+ @shutdown_hook = proc { ErrorTracer.shutdown }
52
+ at_exit(&@shutdown_hook)
53
+ end
54
+
37
55
  def drain_queue
38
56
  loop do
39
57
  payload = @write_queue.pop
@@ -54,43 +72,55 @@ module Legion
54
72
  Legion::Logging.define_singleton_method(:error) do |message = nil, &block|
55
73
  message = block.call if message.nil? && block
56
74
  original_error.call(message)
57
- ErrorTracer.send(:record_trace, message, :error) if message.is_a?(String)
75
+ ErrorTracer.send(:record_trace, message, :error) if message.is_a?(String) && !ErrorTracer.send(:tracing?)
58
76
  end
59
77
 
60
78
  Legion::Logging.define_singleton_method(:fatal) do |message = nil, &block|
61
79
  message = block.call if message.nil? && block
62
80
  original_fatal.call(message)
63
- ErrorTracer.send(:record_trace, message, :fatal) if message.is_a?(String)
81
+ ErrorTracer.send(:record_trace, message, :fatal) if message.is_a?(String) && !ErrorTracer.send(:tracing?)
64
82
  end
65
83
  end
66
84
 
85
+ def tracing?
86
+ Thread.current[:error_tracer_active]
87
+ end
88
+
67
89
  def record_trace(message, level)
68
90
  return unless message.is_a?(String) && !message.empty?
69
91
 
70
- now = Time.now.utc
71
- key = "#{level}:#{message[0..100]}"
72
-
73
- @recent_mutex.synchronize do
74
- return if @recent[key] && (now - @recent[key]) < DEBOUNCE_WINDOW
75
-
76
- @recent[key] = now
77
- @recent.delete_if { |_, t| (now - t) > DEBOUNCE_WINDOW } if @recent.size > 500
92
+ # Guard against infinite recursion if downstream logging triggers error/fatal
93
+ return if tracing?
94
+
95
+ Thread.current[:error_tracer_active] = true
96
+ begin
97
+ now = Time.now.utc
98
+ key = "#{level}:#{message[0..100]}"
99
+
100
+ @recent_mutex.synchronize do
101
+ return if @recent[key] && (now - @recent[key]) < DEBOUNCE_WINDOW
102
+
103
+ @recent[key] = now
104
+ @recent.delete_if { |_, t| (now - t) > DEBOUNCE_WINDOW } if @recent.size > 500
105
+ end
106
+
107
+ component = message.match(/\A\[([^\]]+)\]/)&.captures&.first || 'unknown'
108
+ valence = level == :fatal ? FATAL_VALENCE : ERROR_VALENCE
109
+ intensity = level == :fatal ? FATAL_INTENSITY : ERROR_INTENSITY
110
+
111
+ @write_queue.push(
112
+ type: :episodic,
113
+ content_payload: message,
114
+ domain_tags: ['error', component.downcase],
115
+ origin: :direct_experience,
116
+ emotional_valence: valence,
117
+ emotional_intensity: intensity,
118
+ unresolved: true,
119
+ confidence: 0.9
120
+ )
121
+ ensure
122
+ Thread.current[:error_tracer_active] = false
78
123
  end
79
-
80
- component = message.match(/\A\[([^\]]+)\]/)&.captures&.first || 'unknown'
81
- valence = level == :fatal ? FATAL_VALENCE : ERROR_VALENCE
82
- intensity = level == :fatal ? FATAL_INTENSITY : ERROR_INTENSITY
83
-
84
- @write_queue.push(
85
- type: :episodic,
86
- content_payload: message,
87
- domain_tags: ['error', component.downcase],
88
- origin: :direct_experience,
89
- emotional_valence: valence,
90
- emotional_intensity: intensity,
91
- unresolved: true,
92
- confidence: 0.9
93
- )
94
124
  rescue StandardError
95
125
  nil
96
126
  end
@@ -81,34 +81,101 @@ module Legion
81
81
  end
82
82
 
83
83
  # Serialize a trace hash to a string-only flat hash suitable for Redis HSET.
84
+ # All fields are preserved as strings; arrays/hashes are JSON-encoded.
84
85
  def serialize_trace(trace)
86
+ payload = trace[:content_payload] || trace[:content]
85
87
  {
86
- 'trace_id' => trace[:trace_id].to_s,
87
- 'trace_type' => trace[:trace_type].to_s,
88
- 'content_payload' => trace[:content_payload].to_s,
89
- 'strength' => trace[:strength].to_s,
90
- 'peak_strength' => trace[:peak_strength].to_s,
91
- 'confidence' => trace[:confidence].to_s,
92
- 'storage_tier' => 'hot',
93
- 'partition_id' => trace[:partition_id].to_s,
94
- 'last_reinforced' => (trace[:last_reinforced] || Time.now).to_s
88
+ 'trace_id' => trace[:trace_id].to_s,
89
+ 'trace_type' => trace[:trace_type].to_s,
90
+ 'content_payload' => payload.is_a?(Hash) || payload.is_a?(Array) ? Legion::JSON.dump(payload) : payload.to_s,
91
+ 'strength' => trace[:strength].to_s,
92
+ 'peak_strength' => trace[:peak_strength].to_s,
93
+ 'base_decay_rate' => trace[:base_decay_rate].to_s,
94
+ 'confidence' => trace[:confidence].to_s,
95
+ 'emotional_valence' => trace[:emotional_valence].to_s,
96
+ 'emotional_intensity' => trace[:emotional_intensity].to_s,
97
+ 'storage_tier' => 'hot',
98
+ 'partition_id' => trace[:partition_id].to_s,
99
+ 'origin' => trace[:origin].to_s,
100
+ 'source_agent_id' => trace[:source_agent_id].to_s,
101
+ 'encryption_key_id' => trace[:encryption_key_id].to_s,
102
+ 'parent_trace_id' => trace[:parent_trace_id].to_s,
103
+ 'domain_tags' => trace[:domain_tags].is_a?(Array) ? Legion::JSON.dump(trace[:domain_tags]) : '[]',
104
+ 'associated_traces' => trace[:associated_traces].is_a?(Array) ? Legion::JSON.dump(trace[:associated_traces]) : '[]',
105
+ 'child_trace_ids' => trace[:child_trace_ids].is_a?(Array) ? Legion::JSON.dump(trace[:child_trace_ids]) : '[]',
106
+ 'reinforcement_count' => trace[:reinforcement_count].to_s,
107
+ 'unresolved' => trace[:unresolved].to_s,
108
+ 'consolidation_candidate' => trace[:consolidation_candidate].to_s,
109
+ 'last_reinforced' => (trace[:last_reinforced] || Time.now).to_s,
110
+ 'last_decayed' => trace[:last_decayed].to_s,
111
+ 'created_at' => trace[:created_at].to_s
95
112
  }
96
113
  end
97
114
 
98
115
  # Deserialize a Redis string-hash back to a typed trace hash.
99
116
  def deserialize_trace(data)
100
117
  {
101
- trace_id: data['trace_id'],
102
- trace_type: data['trace_type']&.to_sym,
103
- content_payload: data['content_payload'],
104
- strength: data['strength']&.to_f,
105
- peak_strength: data['peak_strength']&.to_f,
106
- confidence: data['confidence']&.to_f,
107
- storage_tier: :hot,
108
- partition_id: data['partition_id'],
109
- last_reinforced: data['last_reinforced']
118
+ trace_id: data['trace_id'],
119
+ trace_type: data['trace_type']&.to_sym,
120
+ content_payload: parse_json_or_string(data['content_payload']),
121
+ content: parse_json_or_string(data['content_payload']),
122
+ strength: data['strength']&.to_f,
123
+ peak_strength: data['peak_strength']&.to_f,
124
+ base_decay_rate: data['base_decay_rate']&.to_f,
125
+ confidence: data['confidence']&.to_f,
126
+ emotional_valence: data['emotional_valence'].to_f,
127
+ emotional_intensity: data['emotional_intensity'].to_f,
128
+ storage_tier: :hot,
129
+ partition_id: presence(data['partition_id']),
130
+ origin: presence(data['origin'])&.to_sym,
131
+ source_agent_id: presence(data['source_agent_id']),
132
+ encryption_key_id: presence(data['encryption_key_id']),
133
+ parent_trace_id: presence(data['parent_trace_id']),
134
+ domain_tags: parse_json_array(data['domain_tags']),
135
+ associated_traces: parse_json_array(data['associated_traces']),
136
+ child_trace_ids: parse_json_array(data['child_trace_ids']),
137
+ reinforcement_count: data['reinforcement_count'].to_i,
138
+ unresolved: data['unresolved'] == 'true',
139
+ consolidation_candidate: data['consolidation_candidate'] == 'true',
140
+ last_reinforced: data['last_reinforced'],
141
+ last_decayed: presence(data['last_decayed']),
142
+ created_at: presence(data['created_at'])
110
143
  }
111
144
  end
145
+
146
+ # Parse a JSON array string safely; returns [] on failure.
147
+ def parse_json_array(raw)
148
+ return [] if raw.nil? || !raw.is_a?(String) || raw.strip.empty?
149
+
150
+ parsed = Legion::JSON.load(raw)
151
+ parsed.is_a?(Array) ? parsed : []
152
+ rescue StandardError => e
153
+ log.debug "[trace_persistence] parse_json_array: #{e.message}"
154
+ []
155
+ end
156
+
157
+ # Attempt to parse JSON, fall back to raw string.
158
+ def parse_json_or_string(raw)
159
+ return raw unless raw.is_a?(String)
160
+
161
+ stripped = raw.strip
162
+ return raw unless (stripped.start_with?('{') && stripped.end_with?('}')) ||
163
+ (stripped.start_with?('[') && stripped.end_with?(']'))
164
+
165
+ parsed = Legion::JSON.load(stripped)
166
+ parsed.is_a?(Hash) || parsed.is_a?(Array) ? parsed : raw
167
+ rescue StandardError => e
168
+ log.debug "[trace_persistence] parse_json_or_string: #{e.message}"
169
+ raw
170
+ end
171
+
172
+ # Return value only if it is a non-empty string.
173
+ def presence(value)
174
+ return nil unless value.is_a?(String)
175
+
176
+ stripped = value.strip
177
+ stripped.empty? ? nil : stripped
178
+ end
112
179
  end
113
180
  end
114
181
  end
@@ -80,12 +80,22 @@ module Legion
80
80
  []
81
81
  end
82
82
 
83
- # Retrieve traces whose domain_tags column contains the given tag string.
83
+ # Retrieve traces whose domain_tags JSON array contains the given tag.
84
+ # Uses PostgreSQL JSON containment operator (@>) when available for exact array-member
85
+ # matching; falls back to LIKE for other adapters.
84
86
  def retrieve_by_domain(tag, min_strength: 0.0, limit: 50)
85
87
  return [] unless db_ready?
86
88
 
87
- rows = traces_ds
88
- .where(Sequel.like(:domain_tags, "%#{tag}%"))
89
+ ds = traces_ds
90
+ if db.adapter_scheme == :postgres
91
+ # JSONB @> operator matches exact array elements, not substrings
92
+ json_array = ::JSON.dump([tag])
93
+ ds = ds.where(Sequel.lit("domain_tags::jsonb @> '#{json_array}'::jsonb"))
94
+ else
95
+ # Fallback: substring match (imprecise but broadly compatible)
96
+ ds = ds.where(Sequel.like(:domain_tags, "%#{tag}%"))
97
+ end
98
+ rows = ds
89
99
  .where { strength >= min_strength }
90
100
  .order(Sequel.desc(:strength))
91
101
  .limit(limit)
@@ -330,6 +340,7 @@ module Legion
330
340
  parent_trace_id: sanitize_pg_string(trace[:parent_trace_id]),
331
341
  encryption_key_id: sanitize_pg_string(trace[:encryption_key_id]),
332
342
  partition_id: sanitize_pg_string(trace[:partition_id]),
343
+ child_trace_ids: sanitize_pg_string((trace[:child_trace_ids] || []).is_a?(Array) ? Legion::JSON.dump(trace[:child_trace_ids]) : '[]'),
333
344
  created_at: trace[:created_at] || Time.now.utc,
334
345
  accessed_at: Time.now.utc
335
346
  }
@@ -360,7 +371,7 @@ module Legion
360
371
  encryption_key_id: row[:encryption_key_id],
361
372
  associated_traces: parse_json_array(row[:associations]),
362
373
  parent_trace_id: row[:parent_trace_id],
363
- child_trace_ids: [],
374
+ child_trace_ids: parse_json_array(row[:child_trace_ids]),
364
375
  unresolved: row[:unresolved] || false,
365
376
  consolidation_candidate: row[:consolidation_candidate] || false
366
377
  }
@@ -372,7 +383,7 @@ module Legion
372
383
  content_payload: :content,
373
384
  associated_traces: :associations,
374
385
  parent_trace_id: :parent_trace_id,
375
- child_trace_ids: nil # not stored as a column
386
+ child_trace_ids: :child_trace_ids
376
387
  }
377
388
 
378
389
  fields.each_with_object({}) do |(k, v), row|
@@ -382,7 +393,7 @@ module Legion
382
393
  row[col] = case col
383
394
  when :content
384
395
  sanitize_pg_string(v.is_a?(Hash) ? Legion::JSON.dump(v) : v.to_s)
385
- when :associations
396
+ when :associations, :child_trace_ids
386
397
  sanitize_pg_string(v.is_a?(Array) ? Legion::JSON.dump(v) : '[]')
387
398
  when :domain_tags
388
399
  sanitize_pg_string(v.is_a?(Array) ? Legion::JSON.dump(v) : nil)
@@ -394,6 +405,22 @@ module Legion
394
405
  end
395
406
  end
396
407
 
408
+ # Delete all traces of a given type in a single SQL statement,
409
+ # avoiding loading rows into Ruby memory.
410
+ def batch_delete_by_type(trace_type)
411
+ return 0 unless db_ready?
412
+
413
+ ids = traces_ds
414
+ .where(trace_type: trace_type.to_s)
415
+ .select_map(:trace_id)
416
+
417
+ ids.each { |tid| delete(tid) }
418
+ ids.size
419
+ rescue StandardError => e
420
+ log_warn("batch_delete_by_type failed: #{e.message}")
421
+ 0
422
+ end
423
+
397
424
  def parse_json_or_raw(raw)
398
425
  return raw unless raw.is_a?(String)
399
426
 
@@ -117,9 +117,15 @@ module Legion
117
117
  def erase_by_type(type:, store: nil, **)
118
118
  store ||= default_store
119
119
  type = type.to_sym
120
- traces = store.retrieve_by_type(type, min_strength: 0.0, limit: 100_000)
121
- count = traces.size
122
- traces.each { |t| store.delete(t[:trace_id]) }
120
+
121
+ count = if store.respond_to?(:batch_delete_by_type)
122
+ store.batch_delete_by_type(type)
123
+ else
124
+ traces = store.retrieve_by_type(type, min_strength: 0.0, limit: 100_000)
125
+ traces.each { |t| store.delete(t[:trace_id]) }
126
+ traces.size
127
+ end
128
+
123
129
  persist_store(store) if count.positive?
124
130
  log.info("[memory] erased #{count} traces of type=#{type}")
125
131
  { erased: count, type: type }
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Agentic
6
6
  module Memory
7
- VERSION = '0.1.38'
7
+ VERSION = '0.1.39'
8
8
  end
9
9
  end
10
10
  end
@@ -36,6 +36,7 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::PostgresStor
36
36
  String :parent_trace_id, size: 36
37
37
  String :encryption_key_id
38
38
  String :partition_id
39
+ String :child_trace_ids, text: true
39
40
  DateTime :created_at
40
41
  DateTime :accessed_at
41
42
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-agentic-memory
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.38
4
+ version: 0.1.39
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity