lex-agentic-memory 0.1.38 → 0.1.40

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: d111b8e5f52156c7060509c6d50d9c4ffe3f7d02108e7f1fcb6a5ba03f1982d8
4
+ data.tar.gz: 52db96d9807a927c29a097c7a0a47b50d63c43069e657bc7fdad57119a09152a
5
5
  SHA512:
6
- metadata.gz: 68a898cb81e1e16dfbe001a150fd1c5254eac34dedd7477d17c13d360c19117ac4e8d82a402538b44d3d6f7f67bd67d6f87d4fc41d71005010c63d6cc7e58506
7
- data.tar.gz: a52951edc85a09cb417eefafd057acd7be4ab723ced5b0a0fd65c0c89b8a9e6af5f1eebd9e5bdcc62b9154c421d311d3c0d1d4ec05d5934fcab5486eab21c928
6
+ metadata.gz: a538816df808c0bb5da844ababdbb8dca9f02516803c4ec8791b7228f4c70c96269be9232fe69be56e107ac6a08b4d82e57bb3a7931074742420dccae8f22c56
7
+ data.tar.gz: a8c41f78835e8e044ce289c0298727b1e4381598366882715836a2bafd591c8bf88223a388ca4f26cd1be2e9fac2a96a34b6a64f3a3b03c3d2501357928d5211
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.40] - 2026-06-17
4
+ ### Fixed
5
+ - **Critical performance fix:** Trace store `snapshot_dirty_state` no longer serializes ALL traces to JSON on every flush — replaced boolean `@traces_dirty` flag with per-trace `@dirty_trace_ids` Set so only modified traces are serialized (O(changed) instead of O(total))
6
+ - `load_from_local` no longer re-serializes every trace on boot — stores raw DB rows directly in `@persisted_trace_rows`
7
+ - `persist_dirty_traces` now handles explicit deletion tracking via `@deleted_trace_ids` Set
8
+ - Eliminates 97%+ single-core CPU usage caused by `decay_cycle` triggering full-store JSON serialization every 60 seconds
9
+
10
+ ## [0.1.39] - 2026-06-01
11
+ ### Fixed
12
+ - `ErrorTracer` now guards against infinite recursion — if downstream trace storage triggers error/fatal logging, the `tracing?` thread-local flag prevents re-entry
13
+ - `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)
14
+ - `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`
15
+ - `HotTier.deserialize_trace` reconstructs all fields with proper type coercion (arrays via JSON, booleans, integers, symbols, timestamps)
16
+ - `PostgresStore#deserialize_trace` now reads `child_trace_ids` from the DB column instead of hardcoding `[]` — children are no longer silently lost on retrieval
17
+ - `PostgresStore#serialize_trace` now writes `child_trace_ids` to the DB row
18
+ - `PostgresStore#map_update_fields` now maps `child_trace_ids` to the `:child_trace_ids` column (was `nil`, silently dropping partial updates)
19
+ - `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`)
20
+ - `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)
21
+
3
22
  ## [0.1.38] - 2026-05-27
4
23
  ### Fixed
5
24
  - `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
 
@@ -22,7 +22,8 @@ module Legion
22
22
  @traces = {}
23
23
  @associations = Hash.new { |h, k| h[k] = Hash.new(0) }
24
24
  @partition_id = partition_id || resolve_partition_id
25
- @traces_dirty = false
25
+ @dirty_trace_ids = Set.new
26
+ @deleted_trace_ids = Set.new
26
27
  @associations_dirty = false
27
28
  @persisted_trace_rows = {}
28
29
  load_from_local
@@ -32,7 +33,7 @@ module Legion
32
33
  persisted_trace = Helpers::Trace.normalize_trace_affect(trace)
33
34
  persisted_trace[:partition_id] ||= @partition_id
34
35
  @mutex.synchronize do
35
- @traces_dirty = true if @traces[persisted_trace[:trace_id]] != persisted_trace
36
+ @dirty_trace_ids << persisted_trace[:trace_id]
36
37
  @traces[persisted_trace[:trace_id]] = persisted_trace
37
38
  end
38
39
  persisted_trace[:trace_id]
@@ -50,7 +51,10 @@ module Legion
50
51
  removed_links = @associations.delete(trace_id)
51
52
  @associations.each_value { |links| links.delete(trace_id) }
52
53
 
53
- @traces_dirty = true if removed_trace
54
+ if removed_trace
55
+ @dirty_trace_ids.delete(trace_id)
56
+ @deleted_trace_ids << trace_id
57
+ end
54
58
  @associations_dirty = true if removed_trace || removed_links
55
59
  end
56
60
  end
@@ -92,8 +96,10 @@ module Legion
92
96
  @associations_dirty = true
93
97
 
94
98
  threshold = Helpers::Trace::COACTIVATION_THRESHOLD
95
- @traces_dirty = true if @associations[trace_id_a][trace_id_b] >= threshold &&
96
- link_traces(trace_id_a, trace_id_b)
99
+ if @associations[trace_id_a][trace_id_b] >= threshold && link_traces(trace_id_a, trace_id_b)
100
+ @dirty_trace_ids << trace_id_a
101
+ @dirty_trace_ids << trace_id_b
102
+ end
97
103
  end
98
104
  end
99
105
 
@@ -128,7 +134,8 @@ module Legion
128
134
  @mutex.synchronize do
129
135
  @traces = snapshot
130
136
  @associations = Hash.new { |h, k| h[k] = Hash.new(0) }
131
- @traces_dirty = true
137
+ @dirty_trace_ids = Set.new(@traces.keys)
138
+ @deleted_trace_ids = Set.new
132
139
  @associations_dirty = true
133
140
  end
134
141
  flush
@@ -183,27 +190,41 @@ module Legion
183
190
  end
184
191
 
185
192
  def snapshot_dirty_state
186
- traces_snapshot, associations_snapshot, trace_rows_snapshot, trace_changes, associations_dirty = @mutex.synchronize do
187
- ts = @traces.transform_values(&:dup)
193
+ @mutex.synchronize do
194
+ dirty_ids = @dirty_trace_ids.to_a
195
+ deleted_ids = @deleted_trace_ids.to_a
196
+ associations_dirty = @associations_dirty
197
+
198
+ return nil if dirty_ids.empty? && deleted_ids.empty? && !associations_dirty
199
+
200
+ dirty_rows = dirty_ids.each_with_object({}) do |trace_id, h|
201
+ trace = @traces[trace_id]
202
+ h[trace_id] = serialize_trace_for_db(trace) if trace
203
+ end
204
+
205
+ trace_rows_snapshot = @persisted_trace_rows.merge(dirty_rows)
206
+ deleted_ids.each { |id| trace_rows_snapshot.delete(id) }
207
+
208
+ traces_snapshot = @traces.transform_values(&:dup)
188
209
  as = @associations.each_with_object({}) { |(tid, targets), memo| memo[tid] = targets.dup }
189
- trs = ts.transform_values { |trace| serialize_trace_for_db(trace) }
190
- changed_trace_ids = trs.each_key.reject { |trace_id| trs[trace_id] == @persisted_trace_rows[trace_id] }
191
- trace_changes = { dirty: @traces_dirty || changed_trace_ids.any?, changed_ids: changed_trace_ids }
192
- [ts, as, trs, trace_changes, @associations_dirty]
193
- end
194
- return nil unless trace_changes[:dirty] || associations_dirty
210
+ trace_changes = { dirty: true, changed_ids: dirty_ids, deleted_ids: deleted_ids }
195
211
 
196
- [traces_snapshot, associations_snapshot, trace_rows_snapshot, trace_changes, associations_dirty]
212
+ [traces_snapshot, as, trace_rows_snapshot, trace_changes, associations_dirty]
213
+ end
197
214
  end
198
215
 
199
216
  def persist_dirty_traces(db, trace_rows_snapshot, trace_changes, stale_ids)
200
- return unless trace_changes[:dirty] || !stale_ids.empty?
217
+ changed_ids = trace_changes[:changed_ids] || []
218
+ deleted_ids = trace_changes[:deleted_ids] || []
219
+ all_removals = (stale_ids + deleted_ids).uniq
220
+ return if changed_ids.empty? && all_removals.empty?
201
221
 
202
222
  ds = db[:memory_traces]
203
- trace_changes[:changed_ids].each do |trace_id|
204
- ds.insert_conflict(:replace).insert(trace_rows_snapshot.fetch(trace_id))
223
+ changed_ids.each do |trace_id|
224
+ row = trace_rows_snapshot[trace_id]
225
+ ds.insert_conflict(:replace).insert(row) if row
205
226
  end
206
- db[:memory_traces].where(trace_id: stale_ids).delete unless stale_ids.empty?
227
+ db[:memory_traces].where(trace_id: all_removals).delete unless all_removals.empty?
207
228
  end
208
229
 
209
230
  def persist_dirty_associations(db, associations_snapshot, scoped_trace_ids, memory_trace_ids, stale_ids, dirty)
@@ -232,7 +253,8 @@ module Legion
232
253
 
233
254
  def clear_dirty_flags(trace_rows_snapshot)
234
255
  @mutex.synchronize do
235
- @traces_dirty = false
256
+ @dirty_trace_ids.clear
257
+ @deleted_trace_ids.clear
236
258
  @associations_dirty = false
237
259
  @persisted_trace_rows = trace_rows_snapshot
238
260
  end
@@ -246,12 +268,13 @@ module Legion
246
268
 
247
269
  db[:memory_traces].where(partition_id: @partition_id).each do |row|
248
270
  @traces[row[:trace_id]] = deserialize_trace_from_db(row)
271
+ @persisted_trace_rows[row[:trace_id]] = row.dup
249
272
  end
250
273
 
251
274
  load_local_associations(db)
252
275
 
253
- @persisted_trace_rows = @traces.transform_values { |trace| serialize_trace_for_db(trace) }
254
- @traces_dirty = false
276
+ @dirty_trace_ids = Set.new
277
+ @deleted_trace_ids = Set.new
255
278
  @associations_dirty = false
256
279
  end
257
280
 
@@ -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.40'
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.40
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity