lex-agentic-memory 0.1.20 → 0.1.22

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.
Files changed (23) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/Gemfile +2 -0
  4. data/lex-agentic-memory.gemspec +3 -3
  5. data/lib/legion/extensions/agentic/memory/trace/actors/decay.rb +4 -0
  6. data/lib/legion/extensions/agentic/memory/trace/helpers/error_tracer.rb +26 -16
  7. data/lib/legion/extensions/agentic/memory/trace/helpers/hot_tier.rb +26 -11
  8. data/lib/legion/extensions/agentic/memory/trace/helpers/postgres_store.rb +20 -8
  9. data/lib/legion/extensions/agentic/memory/trace/helpers/store.rb +121 -27
  10. data/lib/legion/extensions/agentic/memory/trace/helpers/trace.rb +7 -1
  11. data/lib/legion/extensions/agentic/memory/trace/runners/consolidation.rb +57 -2
  12. data/lib/legion/extensions/agentic/memory/trace/runners/traces.rb +10 -0
  13. data/lib/legion/extensions/agentic/memory/trace.rb +36 -7
  14. data/lib/legion/extensions/agentic/memory/version.rb +1 -1
  15. data/spec/legion/extensions/agentic/memory/trace/actors/decay_spec.rb +6 -0
  16. data/spec/legion/extensions/agentic/memory/trace/helpers/error_tracer_spec.rb +128 -0
  17. data/spec/legion/extensions/agentic/memory/trace/helpers/hot_tier_spec.rb +16 -0
  18. data/spec/legion/extensions/agentic/memory/trace/helpers/postgres_store_spec.rb +13 -0
  19. data/spec/legion/extensions/agentic/memory/trace/helpers/store_spec.rb +26 -1
  20. data/spec/legion/extensions/agentic/memory/trace/local_persistence_spec.rb +73 -3
  21. data/spec/legion/extensions/agentic/memory/trace/memory_spec.rb +27 -0
  22. data/spec/legion/extensions/agentic/memory/trace/runners/consolidation_spec.rb +26 -0
  23. metadata +13 -13
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4e20449f626874ab90b96c4452b4b35e4eae22e61df8fb6c9e5a51e4b0b8cd3
4
- data.tar.gz: eff572db0f462ada591dcfc6e6efa2d8d3092cc43b1be6f907ddcfe9003b0cd9
3
+ metadata.gz: be817722cc940ed946ce77beb9a7c33b5671ea3bf84d206229cdc507dbe85f0a
4
+ data.tar.gz: 73c42aea2da869ecce1de5c02346320531361fd4d563884d6724cd00ffbdb9c0
5
5
  SHA512:
6
- metadata.gz: e0e21635a796f9d4c9fbb2503446657d727bb01de9bb82a5b6323270611ff13c72a18bb0c520f2fb0ab3a987d5a869ca92ebf7e1e718dce0d20cd0b3712f1722
7
- data.tar.gz: 0ea2e0a708e2cb8f853bc17595935a596de7e98c03955c9ce97582a7edcdc5546a22e645f9e929b05a08a43e9b52a4e4a1a45329129b1d797cea58d42f833b76
6
+ metadata.gz: f35dce2fc9fd3d2a1fb7e2eb9e22b2656f4b637d24d5c9635dc737075015ef3b38237b570bb279d7a73e33cfeda5b49f8bbd4b75f5bf8b2f1cd2ee6ca74315f7
7
+ data.tar.gz: 381973ab2893d6b5f651e62b16ae6dbfe166e8b949243ca9f8f3cc717fe58d80012e6cca5a14fce9513eb0abac9b58f8d02978c6ce1707b75afdc34eb85b7b76
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.22] - 2026-04-03
4
+
5
+ ### Changed
6
+ - Refactor `Store#save_to_local` into `snapshot_dirty_state`, `persist_dirty_traces`, `persist_dirty_associations`, and `clear_dirty_flags` helpers to reduce cyclomatic/perceived complexity
7
+ - Restructure `Consolidation#decay_cycle` early return to satisfy `RunnerReturnHash` cop
8
+ - Add exception capture and logging to `Consolidation#trace_count` rescue clause
9
+
10
+ ## [0.1.21] - 2026-04-01
11
+
12
+ ### Changed
13
+ - `ErrorTracer.record_trace` now dispatches `Trace.shared_store` writes/flushes in a background `Thread.new` — error/fatal logging hooks no longer block the calling Puma thread, regardless of store backend (Postgres, CacheStore, or in-memory)
14
+ - Debounce check remains synchronous; only the store write and flush go async
15
+
3
16
  ## [0.1.20] - 2026-03-31
4
17
 
5
18
  ### Added
data/Gemfile CHANGED
@@ -3,3 +3,5 @@
3
3
  source 'https://rubygems.org'
4
4
 
5
5
  gemspec
6
+
7
+ gem 'rubocop-legion'
@@ -34,7 +34,7 @@ Gem::Specification.new do |spec|
34
34
  spec.add_dependency 'msgpack', '~> 1.7'
35
35
 
36
36
  spec.add_development_dependency 'rspec', '~> 3.13'
37
- spec.add_development_dependency 'rubocop', '~> 1.60'
38
- spec.add_development_dependency 'rubocop-legion', '~> 0.1'
39
- spec.add_development_dependency 'rubocop-rspec', '~> 2.26'
37
+ spec.add_development_dependency 'rubocop'
38
+ spec.add_development_dependency 'rubocop-legion'
39
+ spec.add_development_dependency 'rubocop-rspec'
40
40
  end
@@ -17,6 +17,10 @@ module Legion
17
17
  'decay_cycle'
18
18
  end
19
19
 
20
+ def args
21
+ { maintenance: true }
22
+ end
23
+
20
24
  def time
21
25
  60
22
26
  end
@@ -17,8 +17,12 @@ module Legion
17
17
  def setup
18
18
  return if @active
19
19
 
20
- @recent = {}
21
- @runner = Object.new.extend(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces)
20
+ @recent = {}
21
+ @recent_mutex = ::Mutex.new
22
+ @runner = Object.new.extend(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces)
23
+ @write_queue = ::Queue.new
24
+ @worker = ::Thread.new { drain_queue }
25
+ @worker.name = 'legion-error-tracer'
22
26
  wrap_logging_methods
23
27
  @active = true
24
28
  Legion::Logging.info '[memory] ErrorTracer active — errors/fatals will become episodic traces'
@@ -30,6 +34,19 @@ module Legion
30
34
 
31
35
  private
32
36
 
37
+ def drain_queue
38
+ loop do
39
+ payload = @write_queue.pop
40
+ break if payload == :stop
41
+
42
+ @runner.store_trace(**payload)
43
+ store = @runner.send(:default_store)
44
+ store.flush if store.respond_to?(:flush)
45
+ rescue StandardError
46
+ nil
47
+ end
48
+ end
49
+
33
50
  def wrap_logging_methods
34
51
  original_error = Legion::Logging.method(:error)
35
52
  original_fatal = Legion::Logging.method(:fatal)
@@ -50,23 +67,21 @@ module Legion
50
67
  def record_trace(message, level)
51
68
  return unless message.is_a?(String) && !message.empty?
52
69
 
53
- # Debounce: skip if same message within window
54
70
  now = Time.now.utc
55
71
  key = "#{level}:#{message[0..100]}"
56
- return if @recent[key] && (now - @recent[key]) < DEBOUNCE_WINDOW
57
72
 
58
- @recent[key] = now
73
+ @recent_mutex.synchronize do
74
+ return if @recent[key] && (now - @recent[key]) < DEBOUNCE_WINDOW
59
75
 
60
- # Clean old entries periodically
61
- @recent.delete_if { |_, t| (now - t) > DEBOUNCE_WINDOW } if @recent.size > 500
76
+ @recent[key] = now
77
+ @recent.delete_if { |_, t| (now - t) > DEBOUNCE_WINDOW } if @recent.size > 500
78
+ end
62
79
 
63
- # Extract component from [bracket] prefix
64
80
  component = message.match(/\A\[([^\]]+)\]/)&.captures&.first || 'unknown'
65
-
66
81
  valence = level == :fatal ? FATAL_VALENCE : ERROR_VALENCE
67
82
  intensity = level == :fatal ? FATAL_INTENSITY : ERROR_INTENSITY
68
83
 
69
- @runner.store_trace(
84
+ @write_queue.push(
70
85
  type: :episodic,
71
86
  content_payload: message,
72
87
  domain_tags: ['error', component.downcase],
@@ -76,12 +91,7 @@ module Legion
76
91
  unresolved: true,
77
92
  confidence: 0.9
78
93
  )
79
-
80
- # Flush if cache-backed
81
- store = @runner.send(:default_store)
82
- store.flush if store.respond_to?(:flush)
83
- rescue StandardError => _e
84
- # Never let trace creation break the logging pipeline
94
+ rescue StandardError
85
95
  nil
86
96
  end
87
97
  end
@@ -12,24 +12,24 @@ module Legion
12
12
  module_function
13
13
 
14
14
  # Cache a trace in the Redis hot tier.
15
- def cache_trace(trace, tenant_id: nil)
15
+ def cache_trace(trace, tenant_id: nil, agent_id: nil)
16
16
  return unless available?
17
17
 
18
- tid = tenant_id || trace[:partition_id]
19
- key = trace_key(tid, trace[:trace_id])
18
+ scope = cache_scope_id(trace, tenant_id: tenant_id, agent_id: agent_id)
19
+ key = trace_key(scope, trace[:trace_id])
20
20
  data = serialize_trace(trace)
21
21
  Legion::Cache::RedisHash.hset(key, data)
22
22
  Legion::Cache::RedisHash.expire(key, HOT_TTL)
23
23
 
24
- index_key = "legion:tier:hot:#{tid}"
24
+ index_key = "legion:tier:hot:#{scope}"
25
25
  Legion::Cache::RedisHash.zadd(index_key, Time.now.to_f, trace[:trace_id])
26
26
  end
27
27
 
28
28
  # Fetch a trace from the hot tier. Returns a deserialized trace hash or nil on miss.
29
- def fetch_trace(trace_id, tenant_id: nil)
29
+ def fetch_trace(trace_id, tenant_id: nil, agent_id: nil)
30
30
  return nil unless available?
31
31
 
32
- key = trace_key(tenant_id, trace_id)
32
+ key = trace_key(scope_id(tenant_id: tenant_id, agent_id: agent_id), trace_id)
33
33
  data = Legion::Cache::RedisHash.hgetall(key)
34
34
  return nil if data.nil? || data.empty?
35
35
 
@@ -37,13 +37,14 @@ module Legion
37
37
  end
38
38
 
39
39
  # Evict a trace from the hot tier and remove it from the sorted-set index.
40
- def evict_trace(trace_id, tenant_id: nil)
40
+ def evict_trace(trace_id, tenant_id: nil, agent_id: nil)
41
41
  return unless available?
42
42
 
43
- key = trace_key(tenant_id, trace_id)
43
+ scope = scope_id(tenant_id: tenant_id, agent_id: agent_id)
44
+ key = trace_key(scope, trace_id)
44
45
  Legion::Cache.delete(key)
45
46
 
46
- index_key = "legion:tier:hot:#{tenant_id}"
47
+ index_key = "legion:tier:hot:#{scope}"
47
48
  Legion::Cache::RedisHash.zrem(index_key, trace_id)
48
49
  end
49
50
 
@@ -56,8 +57,22 @@ module Legion
56
57
  end
57
58
 
58
59
  # Build the namespaced Redis key for a trace.
59
- def trace_key(tenant_id, trace_id)
60
- "legion:trace:#{tenant_id}:#{trace_id}"
60
+ def trace_key(scope_id, trace_id)
61
+ "legion:trace:#{scope_id}:#{trace_id}"
62
+ end
63
+
64
+ def scope_id(tenant_id: nil, agent_id: nil)
65
+ return tenant_id if tenant_id && agent_id.nil?
66
+ return agent_id if agent_id && tenant_id.nil?
67
+
68
+ [tenant_id, agent_id].compact.join(':')
69
+ end
70
+
71
+ def cache_scope_id(trace, tenant_id: nil, agent_id: nil)
72
+ return scope_id(tenant_id: tenant_id, agent_id: agent_id) if agent_id
73
+ return tenant_id if tenant_id
74
+
75
+ trace[:partition_id]
61
76
  end
62
77
 
63
78
  # Serialize a trace hash to a string-only flat hash suitable for Redis HSET.
@@ -10,7 +10,7 @@ module Legion
10
10
  module Helpers
11
11
  # Write-through durable store backed by Legion::Data (PostgreSQL or MySQL).
12
12
  # All writes go directly to the database — no in-memory dirty tracking, no flush.
13
- # Scoped by tenant_id so multiple agents can share the same DB tables safely.
13
+ # Scoped by tenant_id and agent_id so multiple agents can share the same DB tables safely.
14
14
  class PostgresStore
15
15
  TRACES_TABLE = :memory_traces
16
16
  ASSOCIATIONS_TABLE = :memory_associations
@@ -32,19 +32,19 @@ module Legion
32
32
  else
33
33
  ds.insert_conflict(target: :trace_id, update: row.except(:trace_id)).insert(row)
34
34
  end
35
- HotTier.cache_trace(trace, tenant_id: @tenant_id) if HotTier.available?
35
+ HotTier.cache_trace(trace, tenant_id: @tenant_id, agent_id: @agent_id) if HotTier.available?
36
36
  trace[:trace_id]
37
37
  rescue StandardError => e
38
38
  log_warn("store failed: #{e.message}")
39
39
  nil
40
40
  end
41
41
 
42
- # Retrieve a single trace by trace_id (tenant-scoped).
42
+ # Retrieve a single trace by trace_id (tenant/agent scoped).
43
43
  # Checks the Redis hot tier first; falls through to DB on a miss and caches the result.
44
44
  # Returns a trace hash or nil.
45
45
  def retrieve(trace_id)
46
46
  if HotTier.available?
47
- cached = HotTier.fetch_trace(trace_id, tenant_id: @tenant_id)
47
+ cached = HotTier.fetch_trace(trace_id, tenant_id: @tenant_id, agent_id: @agent_id)
48
48
  return cached if cached
49
49
  end
50
50
 
@@ -52,13 +52,15 @@ module Legion
52
52
 
53
53
  row = traces_ds.where(trace_id: trace_id).first
54
54
  trace = row ? deserialize_trace(row) : nil
55
- HotTier.cache_trace(trace, tenant_id: @tenant_id) if HotTier.available? && trace
55
+ HotTier.cache_trace(trace, tenant_id: @tenant_id, agent_id: @agent_id) if HotTier.available? && trace
56
56
  trace
57
57
  rescue StandardError => e
58
58
  log_warn("retrieve failed: #{e.message}")
59
59
  nil
60
60
  end
61
61
 
62
+ alias get retrieve
63
+
62
64
  # Retrieve traces by type, ordered by strength descending.
63
65
  def retrieve_by_type(type, limit: 100, min_strength: 0.0)
64
66
  return [] unless db_ready?
@@ -105,7 +107,7 @@ module Legion
105
107
 
106
108
  # Delete a trace and its association rows.
107
109
  def delete(trace_id)
108
- HotTier.evict_trace(trace_id, tenant_id: @tenant_id) if HotTier.available?
110
+ HotTier.evict_trace(trace_id, tenant_id: @tenant_id, agent_id: @agent_id) if HotTier.available?
109
111
  return unless db_ready?
110
112
 
111
113
  db[ASSOCIATIONS_TABLE].where(trace_id_a: trace_id).delete
@@ -121,7 +123,7 @@ module Legion
121
123
  return unless db_ready?
122
124
 
123
125
  db[TRACES_TABLE].where(trace_id: trace_id).update(map_update_fields(fields))
124
- HotTier.evict_trace(trace_id, tenant_id: @tenant_id) if HotTier.available?
126
+ HotTier.evict_trace(trace_id, tenant_id: @tenant_id, agent_id: @agent_id) if HotTier.available?
125
127
  rescue StandardError => e
126
128
  log_warn("update failed: #{e.message}")
127
129
  end
@@ -247,6 +249,15 @@ module Legion
247
249
  retrieve_by_type(:firmware)
248
250
  end
249
251
 
252
+ def count
253
+ return 0 unless db_ready?
254
+
255
+ traces_ds.count
256
+ rescue StandardError => e
257
+ log_warn("count failed: #{e.message}")
258
+ 0
259
+ end
260
+
250
261
  # No-op — this store is write-through; nothing to flush.
251
262
  def flush; end
252
263
 
@@ -272,9 +283,10 @@ module Legion
272
283
  'default'
273
284
  end
274
285
 
275
- # Dataset for memory_traces scoped by tenant_id (if set).
286
+ # Dataset for memory_traces scoped by agent_id and tenant_id (if set).
276
287
  def traces_ds
277
288
  ds = db[TRACES_TABLE]
289
+ ds = ds.where(agent_id: @agent_id)
278
290
  @tenant_id ? ds.where(tenant_id: @tenant_id) : ds
279
291
  end
280
292
 
@@ -13,27 +13,41 @@ module Legion
13
13
  class Store
14
14
  attr_reader :traces, :associations
15
15
 
16
- def initialize
16
+ def initialize(partition_id: nil)
17
17
  @mutex = Mutex.new
18
18
  @traces = {}
19
19
  @associations = Hash.new { |h, k| h[k] = Hash.new(0) }
20
+ @partition_id = partition_id || resolve_partition_id
21
+ @traces_dirty = false
22
+ @associations_dirty = false
23
+ @persisted_trace_rows = {}
20
24
  load_from_local
21
25
  end
22
26
 
23
27
  def store(trace)
24
- @mutex.synchronize { @traces[trace[:trace_id]] = trace }
25
- trace[:trace_id]
28
+ persisted_trace = trace.dup
29
+ persisted_trace[:partition_id] ||= @partition_id
30
+ @mutex.synchronize do
31
+ @traces_dirty = true if @traces[persisted_trace[:trace_id]] != persisted_trace
32
+ @traces[persisted_trace[:trace_id]] = persisted_trace
33
+ end
34
+ persisted_trace[:trace_id]
26
35
  end
27
36
 
28
37
  def get(trace_id)
29
- @traces[trace_id]
38
+ @mutex.synchronize { @traces[trace_id] }
30
39
  end
31
40
 
32
41
  def delete(trace_id)
33
42
  @mutex.synchronize do
34
- @traces.delete(trace_id)
35
- @associations.delete(trace_id)
43
+ removed_trace = @traces.delete(trace_id)
44
+ @traces.each_value { |trace| trace[:associated_traces]&.delete(trace_id) }
45
+
46
+ removed_links = @associations.delete(trace_id)
36
47
  @associations.each_value { |links| links.delete(trace_id) }
48
+
49
+ @traces_dirty = true if removed_trace
50
+ @associations_dirty = true if removed_trace || removed_links
37
51
  end
38
52
  end
39
53
 
@@ -68,9 +82,11 @@ module Legion
68
82
  @mutex.synchronize do
69
83
  @associations[trace_id_a][trace_id_b] += 1
70
84
  @associations[trace_id_b][trace_id_a] += 1
85
+ @associations_dirty = true
71
86
 
72
87
  threshold = Helpers::Trace::COACTIVATION_THRESHOLD
73
- link_traces(trace_id_a, trace_id_b) if @associations[trace_id_a][trace_id_b] >= threshold
88
+ @traces_dirty = true if @associations[trace_id_a][trace_id_b] >= threshold &&
89
+ link_traces(trace_id_a, trace_id_b)
74
90
  end
75
91
  end
76
92
 
@@ -80,7 +96,7 @@ module Legion
80
96
  end
81
97
 
82
98
  def count
83
- @traces.size
99
+ @mutex.synchronize { @traces.size }
84
100
  end
85
101
 
86
102
  def synchronize(&) = @mutex.synchronize(&)
@@ -89,6 +105,28 @@ module Legion
89
105
  retrieve_by_type(:firmware)
90
106
  end
91
107
 
108
+ def flush
109
+ save_to_local
110
+ end
111
+
112
+ def restore_traces(traces)
113
+ snapshot = Array(traces).each_with_object({}) do |trace, memo|
114
+ next unless trace.is_a?(Hash) && trace[:trace_id]
115
+
116
+ restored = trace.dup
117
+ restored[:partition_id] ||= @partition_id
118
+ memo[restored[:trace_id]] = restored
119
+ end
120
+
121
+ @mutex.synchronize do
122
+ @traces = snapshot
123
+ @associations = Hash.new { |h, k| h[k] = Hash.new(0) }
124
+ @traces_dirty = true
125
+ @associations_dirty = true
126
+ end
127
+ flush
128
+ end
129
+
92
130
  def walk_associations(start_id:, max_hops: 12, min_strength: 0.1)
93
131
  snapshot = @mutex.synchronize { @traces.dup }
94
132
  return [] unless snapshot.key?(start_id)
@@ -123,49 +161,96 @@ module Legion
123
161
  return unless Legion::Data::Local.connection.table_exists?(:memory_traces)
124
162
 
125
163
  db = Legion::Data::Local.connection
126
- traces_snapshot = @mutex.synchronize { @traces.dup }
164
+ snapshots = snapshot_dirty_state
165
+ return unless snapshots
127
166
 
128
- traces_snapshot.each_value do |trace|
129
- row = serialize_trace_for_db(trace)
130
- existing = db[:memory_traces].where(trace_id: trace[:trace_id]).first
131
- if existing
132
- db[:memory_traces].where(trace_id: trace[:trace_id]).update(row)
167
+ traces_snapshot, associations_snapshot, trace_rows_snapshot, traces_dirty, associations_dirty = snapshots
168
+ scoped_trace_ids = db[:memory_traces].where(partition_id: @partition_id).select_map(:trace_id)
169
+ memory_trace_ids = traces_snapshot.keys
170
+ stale_ids = persist_dirty_traces(db, trace_rows_snapshot, scoped_trace_ids, memory_trace_ids, traces_dirty)
171
+ persist_dirty_associations(db, associations_snapshot, scoped_trace_ids, memory_trace_ids, stale_ids, associations_dirty)
172
+ clear_dirty_flags(trace_rows_snapshot)
173
+ end
174
+
175
+ def snapshot_dirty_state
176
+ traces_snapshot, associations_snapshot, trace_rows_snapshot, traces_dirty, associations_dirty = @mutex.synchronize do
177
+ ts = @traces.transform_values(&:dup)
178
+ as = @associations.each_with_object({}) { |(tid, targets), memo| memo[tid] = targets.dup }
179
+ trs = ts.transform_values { |trace| serialize_trace_for_db(trace) }
180
+ [ts, as, trs, @traces_dirty || trs != @persisted_trace_rows, @associations_dirty]
181
+ end
182
+ return nil unless traces_dirty || associations_dirty
183
+
184
+ [traces_snapshot, associations_snapshot, trace_rows_snapshot, traces_dirty, associations_dirty]
185
+ end
186
+
187
+ def persist_dirty_traces(db, trace_rows_snapshot, scoped_trace_ids, memory_trace_ids, traces_dirty)
188
+ return [] unless traces_dirty
189
+
190
+ trace_rows_snapshot.each do |trace_id, row|
191
+ if db[:memory_traces].where(trace_id: trace_id).first
192
+ db[:memory_traces].where(trace_id: trace_id).update(row)
133
193
  else
134
194
  db[:memory_traces].insert(row)
135
195
  end
136
196
  end
137
-
138
- db_trace_ids = db[:memory_traces].select_map(:trace_id)
139
- memory_trace_ids = traces_snapshot.keys
140
- stale_ids = db_trace_ids - memory_trace_ids
197
+ stale_ids = scoped_trace_ids - memory_trace_ids
141
198
  db[:memory_traces].where(trace_id: stale_ids).delete unless stale_ids.empty?
199
+ stale_ids
200
+ end
142
201
 
143
- db[:memory_associations].delete
144
- @associations.each do |id_a, targets|
202
+ def persist_dirty_associations(db, associations_snapshot, scoped_trace_ids, memory_trace_ids, stale_ids, dirty)
203
+ assoc_scope_ids = (scoped_trace_ids + memory_trace_ids).uniq
204
+ return unless (dirty || !stale_ids.empty?) && !assoc_scope_ids.empty?
205
+
206
+ db[:memory_associations].where(trace_id_a: assoc_scope_ids).delete
207
+ db[:memory_associations].where(trace_id_b: assoc_scope_ids).delete
208
+ associations_snapshot.each do |id_a, targets|
145
209
  targets.each do |id_b, count|
146
210
  db[:memory_associations].insert(trace_id_a: id_a, trace_id_b: id_b, coactivation_count: count)
147
211
  end
148
212
  end
149
213
  end
150
214
 
215
+ def clear_dirty_flags(trace_rows_snapshot)
216
+ @mutex.synchronize do
217
+ @traces_dirty = false
218
+ @associations_dirty = false
219
+ @persisted_trace_rows = trace_rows_snapshot
220
+ end
221
+ end
222
+
151
223
  def load_from_local
152
224
  return unless defined?(Legion::Data::Local) && Legion::Data::Local.connected?
153
225
  return unless Legion::Data::Local.connection.table_exists?(:memory_traces)
154
226
 
155
227
  db = Legion::Data::Local.connection
156
228
 
157
- db[:memory_traces].each do |row|
229
+ db[:memory_traces].where(partition_id: @partition_id).each do |row|
158
230
  @traces[row[:trace_id]] = deserialize_trace_from_db(row)
159
231
  end
160
232
 
161
- db[:memory_associations].each do |row|
162
- @associations[row[:trace_id_a]] ||= {}
163
- @associations[row[:trace_id_a]][row[:trace_id_b]] = row[:coactivation_count]
233
+ trace_ids = @traces.keys
234
+ unless trace_ids.empty?
235
+ db[:memory_associations].where(trace_id_a: trace_ids).each do |row|
236
+ @associations[row[:trace_id_a]] ||= {}
237
+ @associations[row[:trace_id_a]][row[:trace_id_b]] = row[:coactivation_count]
238
+ end
164
239
  end
240
+
241
+ @persisted_trace_rows = @traces.transform_values { |trace| serialize_trace_for_db(trace) }
242
+ @traces_dirty = false
243
+ @associations_dirty = false
165
244
  end
166
245
 
167
246
  private
168
247
 
248
+ def resolve_partition_id
249
+ Legion::Settings.dig(:agent, :id) || 'default'
250
+ rescue StandardError => _e
251
+ 'default'
252
+ end
253
+
169
254
  def serialize_trace_for_db(trace)
170
255
  payload = trace[:content_payload] || trace[:content]
171
256
  {
@@ -253,10 +338,19 @@ module Legion
253
338
  return unless trace_a && trace_b
254
339
 
255
340
  max = Helpers::Trace::MAX_ASSOCIATIONS
256
- trace_a[:associated_traces] << id_b unless trace_a[:associated_traces].include?(id_b) || trace_a[:associated_traces].size >= max
257
- return if trace_b[:associated_traces].include?(id_a) || trace_b[:associated_traces].size >= max
341
+ changed = false
342
+
343
+ unless trace_a[:associated_traces].include?(id_b) || trace_a[:associated_traces].size >= max
344
+ trace_a[:associated_traces] << id_b
345
+ changed = true
346
+ end
347
+
348
+ unless trace_b[:associated_traces].include?(id_a) || trace_b[:associated_traces].size >= max
349
+ trace_b[:associated_traces] << id_a
350
+ changed = true
351
+ end
258
352
 
259
- trace_b[:associated_traces] << id_a
353
+ changed
260
354
  end
261
355
  end
262
356
  end
@@ -79,7 +79,7 @@ module Legion
79
79
  reinforcement_count: imprint_active ? 1 : 0,
80
80
  confidence: confidence || (type == :firmware ? 1.0 : 0.5),
81
81
  storage_tier: :hot,
82
- partition_id: partition_id,
82
+ partition_id: partition_id || default_partition_id,
83
83
  encryption_key_id: nil,
84
84
  associated_traces: [],
85
85
  parent_trace_id: nil,
@@ -97,6 +97,12 @@ module Legion
97
97
 
98
98
  true
99
99
  end
100
+
101
+ def default_partition_id
102
+ Legion::Settings.dig(:agent, :id) || 'default'
103
+ rescue StandardError => _e
104
+ 'default'
105
+ end
100
106
  end
101
107
  end
102
108
  end
@@ -24,15 +24,22 @@ module Legion
24
24
  trace[:reinforcement_count] += 1
25
25
 
26
26
  store.store(trace)
27
+ persist_store(store)
27
28
 
28
29
  log.debug("[memory] reinforced #{trace_id[0..7]} strength=#{new_strength.round(3)}#{' (imprint 3x)' if imprint_active}")
29
30
  { found: true, reinforced: true, trace_id: trace_id, new_strength: new_strength }
30
31
  end
31
32
 
32
- def decay_cycle(store: nil, tick_count: 1, **)
33
+ def decay_cycle(store: nil, tick_count: 1, maintenance: true, **)
33
34
  store ||= default_store
35
+ unless maintenance
36
+ deferred = deferred_decay_summary(store)
37
+ return { **deferred }
38
+ end
39
+
34
40
  decayed = 0
35
41
  pruned = 0
42
+ total = trace_count(store)
36
43
 
37
44
  store.all_traces.each do |trace|
38
45
  next if trace[:base_decay_rate].zero?
@@ -55,8 +62,20 @@ module Legion
55
62
  end
56
63
  end
57
64
 
65
+ persist_store(store) if decayed.positive? || pruned.positive?
66
+
67
+ remaining = trace_count(store)
68
+ summary = {
69
+ decayed: decayed,
70
+ pruned: pruned,
71
+ total: total,
72
+ remaining: remaining,
73
+ maintained_at: Time.now.utc
74
+ }
75
+ Legion::Extensions::Agentic::Memory::Trace.record_maintenance_summary(summary.dup)
76
+
58
77
  log.debug("[memory] decay cycle: decayed=#{decayed} pruned=#{pruned}")
59
- { decayed: decayed, pruned: pruned }
78
+ summary
60
79
  end
61
80
 
62
81
  def migrate_tier(store: nil, **)
@@ -73,6 +92,7 @@ module Legion
73
92
  migrated += 1
74
93
  end
75
94
 
95
+ persist_store(store) if migrated.positive?
76
96
  log.debug("[memory] tier migration: migrated=#{migrated}")
77
97
  { migrated: migrated }
78
98
  end
@@ -82,6 +102,7 @@ module Legion
82
102
 
83
103
  store ||= default_store
84
104
  store.record_coactivation(trace_id_a, trace_id_b)
105
+ persist_store(store)
85
106
  log.debug("[memory] hebbian link #{trace_id_a[0..7]} <-> #{trace_id_b[0..7]}")
86
107
  { linked: true }
87
108
  end
@@ -99,6 +120,7 @@ module Legion
99
120
  traces = store.retrieve_by_type(type, min_strength: 0.0, limit: 100_000)
100
121
  count = traces.size
101
122
  traces.each { |t| store.delete(t[:trace_id]) }
123
+ persist_store(store) if count.positive?
102
124
  log.info("[memory] erased #{count} traces of type=#{type}")
103
125
  { erased: count, type: type }
104
126
  end
@@ -108,12 +130,45 @@ module Legion
108
130
  traces = store.all_traces.select { |t| t[:partition_id] == partition_id }
109
131
  count = traces.size
110
132
  traces.each { |t| store.delete(t[:trace_id]) }
133
+ persist_store(store) if count.positive?
111
134
  log.info("[memory] erased #{count} traces for partition=#{partition_id}")
112
135
  { erased: count, partition_id: partition_id }
113
136
  end
114
137
 
115
138
  private
116
139
 
140
+ def deferred_decay_summary(store)
141
+ summary = Legion::Extensions::Agentic::Memory::Trace.last_maintenance_summary || {}
142
+ current_count = trace_count(store)
143
+
144
+ {
145
+ decayed: summary[:decayed] || 0,
146
+ pruned: summary[:pruned] || 0,
147
+ total: summary[:total] || current_count,
148
+ remaining: summary[:remaining] || current_count,
149
+ maintained_at: summary[:maintained_at],
150
+ deferred: true,
151
+ reason: :background_decay_actor
152
+ }
153
+ end
154
+
155
+ def trace_count(store)
156
+ if store.respond_to?(:count)
157
+ store.count
158
+ else
159
+ store.all_traces.size
160
+ end
161
+ rescue StandardError => e
162
+ log.debug("[memory] trace_count fallback: #{e.message}")
163
+ store.all_traces.size
164
+ end
165
+
166
+ def persist_store(store)
167
+ store.flush if store.respond_to?(:flush)
168
+ rescue StandardError => e
169
+ log.debug("[memory] persist_store skipped: #{e.message}")
170
+ end
171
+
117
172
  def default_store
118
173
  @default_store ||= Legion::Extensions::Agentic::Memory::Trace.shared_store
119
174
  end
@@ -11,6 +11,7 @@ module Legion
11
11
  store ||= default_store
12
12
  trace = Helpers::Trace.new_trace(type: type.to_sym, content_payload: content_payload, **)
13
13
  store.store(trace)
14
+ persist_store(store)
14
15
  log.debug("[memory] stored trace #{trace[:trace_id][0..7]} type=#{trace[:trace_type]} strength=#{trace[:strength].round(2)}")
15
16
  { trace_id: trace[:trace_id], trace_type: trace[:trace_type], strength: trace[:strength] }
16
17
  end
@@ -68,6 +69,7 @@ module Legion
68
69
  def delete_trace(trace_id:, store: nil, **)
69
70
  store ||= default_store
70
71
  store.delete(trace_id)
72
+ persist_store(store)
71
73
  log.debug("[memory] deleted trace #{trace_id[0..7]}")
72
74
  { deleted: true, trace_id: trace_id }
73
75
  end
@@ -85,12 +87,20 @@ module Legion
85
87
  store.store(trace)
86
88
  end
87
89
 
90
+ persist_store(store) unless top.empty?
91
+
88
92
  log.debug("[memory] retrieve_and_reinforce: retrieved=#{top.size} from=#{all.size} total")
89
93
  { count: top.size, traces: top }
90
94
  end
91
95
 
92
96
  private
93
97
 
98
+ def persist_store(store)
99
+ store.flush if store.respond_to?(:flush)
100
+ rescue StandardError => e
101
+ log.debug("[memory] persist_store skipped: #{e.message}")
102
+ end
103
+
94
104
  def default_store
95
105
  @default_store ||= Legion::Extensions::Agentic::Memory::Trace.shared_store
96
106
  end
@@ -17,33 +17,62 @@ module Legion
17
17
  module Memory
18
18
  module Trace
19
19
  class << self
20
- # Process-wide shared store. All memory runners delegate here so that
21
- # traces written by one component (ErrorTracer, coldstart, tick) are
22
- # visible to every other component (dream cycle, cortex, predictions).
23
- # CacheStore adds cross-process sharing via memcached on top of this.
20
+ # Process-wide default trace store. All memory runners delegate here so
21
+ # traces written by one component remain visible to the rest of the
22
+ # current agent runtime. Raw trace storage prefers agent-local durable
23
+ # state and only falls back to shared stores when explicitly requested
24
+ # or when local persistence is unavailable.
24
25
  def shared_store
25
26
  @shared_store ||= create_store
26
27
  end
27
28
 
29
+ def last_maintenance_summary
30
+ @maintenance_summary
31
+ end
32
+
33
+ def record_maintenance_summary(summary)
34
+ @maintenance_summary = summary
35
+ end
36
+
28
37
  def reset_store!
29
38
  @shared_store = nil
39
+ @maintenance_summary = nil
30
40
  end
31
41
 
32
42
  private
33
43
 
34
44
  def create_store
35
- if postgres_available?
45
+ if local_store_available? && configured_trace_store != :shared
46
+ Legion::Logging.debug '[memory] Using agent-local Store (Data::Local-backed)'
47
+ Helpers::Store.new(partition_id: resolve_agent_id)
48
+ elsif postgres_available?
36
49
  Legion::Logging.debug '[memory] Using shared PostgresStore (write-through)'
37
50
  Helpers::PostgresStore.new(tenant_id: resolve_tenant_id, agent_id: resolve_agent_id)
38
51
  elsif defined?(Legion::Cache) && Legion::Cache.respond_to?(:connected?) && Legion::Cache.connected?
39
52
  Legion::Logging.debug '[memory] Using shared CacheStore (memcached)'
40
53
  Helpers::CacheStore.new
41
54
  else
42
- Legion::Logging.debug '[memory] Using shared in-memory Store'
43
- Helpers::Store.new
55
+ Legion::Logging.debug '[memory] Using agent-local in-memory Store'
56
+ Helpers::Store.new(partition_id: resolve_agent_id)
44
57
  end
45
58
  end
46
59
 
60
+ def configured_trace_store
61
+ Legion::Settings.dig(:memory, :trace_store)&.to_sym
62
+ rescue StandardError => _e
63
+ nil
64
+ end
65
+
66
+ def local_store_available?
67
+ defined?(Legion::Data::Local) &&
68
+ Legion::Data::Local.respond_to?(:connected?) &&
69
+ Legion::Data::Local.connected? &&
70
+ Legion::Data::Local.connection&.table_exists?(:memory_traces) &&
71
+ Legion::Data::Local.connection.table_exists?(:memory_associations)
72
+ rescue StandardError => _e
73
+ false
74
+ end
75
+
47
76
  def postgres_available?
48
77
  defined?(Legion::Data) &&
49
78
  Legion::Data.respond_to?(:connection) &&
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Agentic
6
6
  module Memory
7
- VERSION = '0.1.20'
7
+ VERSION = '0.1.22'
8
8
  end
9
9
  end
10
10
  end
@@ -30,6 +30,12 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Actor::Decay do
30
30
  end
31
31
  end
32
32
 
33
+ describe '#args' do
34
+ it 'runs full maintenance when scheduled in the background' do
35
+ expect(actor.args).to eq({ maintenance: true })
36
+ end
37
+ end
38
+
33
39
  describe '#time' do
34
40
  it 'returns 60 seconds' do
35
41
  expect(actor.time).to eq(60)
@@ -11,13 +11,24 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::ErrorTracer
11
11
  # Reset state between examples
12
12
  described_class.instance_variable_set(:@active, nil)
13
13
  described_class.instance_variable_set(:@recent, nil)
14
+ described_class.instance_variable_set(:@recent_mutex, nil)
14
15
  described_class.instance_variable_set(:@runner, nil)
16
+ described_class.instance_variable_set(:@write_queue, nil)
17
+ old_worker = described_class.instance_variable_get(:@worker)
18
+ old_worker&.kill
19
+ described_class.instance_variable_set(:@worker, nil)
15
20
  end
16
21
 
17
22
  after do
18
23
  # Restore logging singleton methods to prevent cross-test side effects
19
24
  Legion::Logging.define_singleton_method(:error, &original_error)
20
25
  Legion::Logging.define_singleton_method(:fatal, &original_fatal)
26
+ # Stop worker thread if still alive
27
+ worker = described_class.instance_variable_get(:@worker)
28
+ if worker&.alive?
29
+ described_class.instance_variable_get(:@write_queue)&.push(:stop)
30
+ worker.join(1)
31
+ end
21
32
  end
22
33
 
23
34
  describe '.setup' do
@@ -31,6 +42,13 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::ErrorTracer
31
42
  described_class.setup
32
43
  expect(described_class.active?).to be true
33
44
  end
45
+
46
+ it 'starts a single background worker thread' do
47
+ described_class.setup
48
+ worker = described_class.instance_variable_get(:@worker)
49
+ expect(worker).to be_a(Thread)
50
+ expect(worker).to be_alive
51
+ end
34
52
  end
35
53
 
36
54
  describe '.active?' do
@@ -43,4 +61,114 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::ErrorTracer
43
61
  expect(described_class.active?).to be true
44
62
  end
45
63
  end
64
+
65
+ describe 'record_trace (async dispatch via queue)' do
66
+ let(:store_dbl) { double('store', flush: nil) }
67
+ let(:runner_double) do
68
+ dbl = double('runner')
69
+ allow(dbl).to receive(:store_trace)
70
+ allow(dbl).to receive(:default_store).and_return(store_dbl)
71
+ dbl
72
+ end
73
+ let(:write_queue) { Queue.new }
74
+
75
+ before do
76
+ described_class.instance_variable_set(:@active, true)
77
+ described_class.instance_variable_set(:@recent, {})
78
+ described_class.instance_variable_set(:@recent_mutex, Mutex.new)
79
+ described_class.instance_variable_set(:@runner, runner_double)
80
+ described_class.instance_variable_set(:@write_queue, write_queue)
81
+ end
82
+
83
+ it 'enqueues a payload hash (fire-and-forget dispatch)' do
84
+ described_class.send(:record_trace, 'something broke', :error)
85
+ expect(write_queue.size).to eq(1)
86
+ payload = write_queue.pop
87
+ expect(payload).to be_a(Hash)
88
+ end
89
+
90
+ it 'enqueues a payload with expected keys' do
91
+ described_class.send(:record_trace, '[mycomponent] disk full', :error)
92
+ payload = write_queue.pop
93
+ expect(payload).to include(
94
+ type: :episodic,
95
+ content_payload: '[mycomponent] disk full',
96
+ domain_tags: %w[error mycomponent],
97
+ unresolved: true
98
+ )
99
+ end
100
+
101
+ it 'uses fatal valence and intensity for :fatal level' do
102
+ described_class.send(:record_trace, 'total meltdown', :fatal)
103
+ payload = write_queue.pop
104
+ expect(payload).to include(
105
+ emotional_valence: Legion::Extensions::Agentic::Memory::Trace::Helpers::ErrorTracer::FATAL_VALENCE,
106
+ emotional_intensity: Legion::Extensions::Agentic::Memory::Trace::Helpers::ErrorTracer::FATAL_INTENSITY
107
+ )
108
+ end
109
+
110
+ it 'does not enqueue (debounced) when same message is within the window' do
111
+ described_class.send(:record_trace, 'repeated error', :error)
112
+ described_class.send(:record_trace, 'repeated error', :error)
113
+ expect(write_queue.size).to eq(1)
114
+ end
115
+
116
+ it 'returns nil for blank messages without raising' do
117
+ expect(described_class.send(:record_trace, '', :error)).to be_nil
118
+ expect(write_queue.size).to eq(0)
119
+ end
120
+
121
+ it 'does not propagate errors from store_trace' do
122
+ worker_queue = Queue.new
123
+ described_class.instance_variable_set(:@write_queue, worker_queue)
124
+ allow(runner_double).to receive(:store_trace).and_raise(StandardError, 'db down')
125
+
126
+ # rubocop:disable ThreadSafety/NewThread
127
+ worker = Thread.new do
128
+ payload = worker_queue.pop
129
+ break if payload == :stop
130
+
131
+ runner_double.store_trace(**payload)
132
+ rescue StandardError
133
+ nil
134
+ end
135
+ # rubocop:enable ThreadSafety/NewThread
136
+
137
+ described_class.send(:record_trace, 'store failure', :error)
138
+ expect { worker.join(2) }.not_to raise_error
139
+ end
140
+
141
+ it 'the background worker calls store_trace with the enqueued payload' do
142
+ dedicated_queue = Queue.new
143
+ described_class.instance_variable_set(:@write_queue, dedicated_queue)
144
+
145
+ # rubocop:disable ThreadSafety/NewThread
146
+ worker = Thread.new do
147
+ loop do
148
+ payload = dedicated_queue.pop
149
+ break if payload == :stop
150
+
151
+ runner_double.store_trace(**payload)
152
+ store = runner_double.send(:default_store)
153
+ store.flush if store.respond_to?(:flush)
154
+ rescue StandardError
155
+ nil
156
+ end
157
+ end
158
+ # rubocop:enable ThreadSafety/NewThread
159
+
160
+ described_class.send(:record_trace, '[mycomponent] disk full', :error)
161
+ dedicated_queue.push(:stop)
162
+ worker.join(2)
163
+
164
+ expect(runner_double).to have_received(:store_trace).with(
165
+ hash_including(
166
+ type: :episodic,
167
+ content_payload: '[mycomponent] disk full',
168
+ domain_tags: %w[error mycomponent],
169
+ unresolved: true
170
+ )
171
+ )
172
+ end
173
+ end
46
174
  end
@@ -97,6 +97,16 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::HotTier do
97
97
  end
98
98
  end
99
99
 
100
+ describe '.scope_id' do
101
+ it 'uses tenant scope when agent_id is not provided' do
102
+ expect(mod.scope_id(tenant_id: 'tenant-1')).to eq('tenant-1')
103
+ end
104
+
105
+ it 'uses combined tenant and agent scope when both are provided' do
106
+ expect(mod.scope_id(tenant_id: 'tenant-1', agent_id: 'agent-1')).to eq('tenant-1:agent-1')
107
+ end
108
+ end
109
+
100
110
  # --- serialize_trace / deserialize_trace round-trip ---
101
111
 
102
112
  describe '.serialize_trace' do
@@ -239,6 +249,12 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::HotTier do
239
249
  expect(Legion::Cache::RedisHash).to receive(:hset).with(key, anything)
240
250
  mod.cache_trace(trace)
241
251
  end
252
+
253
+ it 'uses a combined tenant and agent scope when both are provided' do
254
+ key = mod.trace_key('tenant-abc:agent-1', trace_id)
255
+ expect(Legion::Cache::RedisHash).to receive(:hset).with(key, anything)
256
+ mod.cache_trace(trace, tenant_id: tenant_id, agent_id: 'agent-1')
257
+ end
242
258
  end
243
259
  end
244
260
 
@@ -179,6 +179,12 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::PostgresStor
179
179
  expect(other_store.retrieve(semantic_trace[:trace_id])).to be_nil
180
180
  end
181
181
 
182
+ it 'scopes retrieve to the correct agent within the same tenant' do
183
+ store.store(semantic_trace)
184
+ other_store = described_class.new(tenant_id: tenant_id, agent_id: 'other-agent')
185
+ expect(other_store.retrieve(semantic_trace[:trace_id])).to be_nil
186
+ end
187
+
182
188
  it 'returns nil when db not ready' do
183
189
  db.drop_table(:memory_traces)
184
190
  expect(store.retrieve(semantic_trace[:trace_id])).to be_nil
@@ -301,6 +307,13 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::PostgresStor
301
307
  expect(store.all_traces.size).to eq(1)
302
308
  end
303
309
 
310
+ it 'does not include traces from another agent in the same tenant' do
311
+ store.store(semantic_trace)
312
+ other = described_class.new(tenant_id: tenant_id, agent_id: 'other-agent')
313
+ other.store(episodic_trace)
314
+ expect(store.all_traces.map { |trace| trace[:trace_id] }).to eq([semantic_trace[:trace_id]])
315
+ end
316
+
304
317
  it 'returns empty array when db not ready' do
305
318
  db.drop_table(:memory_traces)
306
319
  expect(store.all_traces).to eq([])
@@ -15,6 +15,14 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::Store do
15
15
  expect(result[:trace_type]).to eq(:semantic)
16
16
  end
17
17
 
18
+ it 'assigns the store partition_id when a trace does not already have one' do
19
+ semantic_trace[:partition_id] = nil
20
+
21
+ store.store(semantic_trace)
22
+ result = store.get(semantic_trace[:trace_id])
23
+ expect(result[:partition_id]).to eq('default')
24
+ end
25
+
18
26
  it 'returns nil for unknown trace_id' do
19
27
  expect(store.get('nonexistent')).to be_nil
20
28
  end
@@ -194,7 +202,7 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::Store do
194
202
  end
195
203
 
196
204
  it 'filters by min_strength and does not traverse beyond filtered nodes' do
197
- trace_b[:strength] = 0.05
205
+ store.traces[trace_b[:trace_id]][:strength] = 0.05
198
206
  results = store.walk_associations(start_id: trace_a[:trace_id], min_strength: 0.1)
199
207
  found_ids = results.map { |r| r[:trace_id] }
200
208
  expect(found_ids).not_to include(trace_b[:trace_id])
@@ -214,4 +222,21 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::Store do
214
222
  expect(results).to eq([])
215
223
  end
216
224
  end
225
+
226
+ describe '#restore_traces' do
227
+ it 'replaces existing traces and clears stale associations' do
228
+ store.store(semantic_trace)
229
+ store.store(episodic_trace)
230
+
231
+ threshold = Legion::Extensions::Agentic::Memory::Trace::Helpers::Trace::COACTIVATION_THRESHOLD
232
+ threshold.times { store.record_coactivation(semantic_trace[:trace_id], episodic_trace[:trace_id]) }
233
+
234
+ replacement = trace_helper.new_trace(type: :semantic, content_payload: { fact: 'replacement' })
235
+ store.restore_traces([replacement])
236
+
237
+ expect(store.count).to eq(1)
238
+ expect(store.get(semantic_trace[:trace_id])).to be_nil
239
+ expect(store.associations).to be_empty
240
+ end
241
+ end
217
242
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'sequel'
4
4
  require 'sequel/extensions/migration'
5
+ require 'logger'
6
+ require 'stringio'
5
7
 
6
8
  # Minimal stub for Legion::Data::Local used in persistence specs.
7
9
  # Does not require the full legion-data gem.
@@ -35,9 +37,9 @@ module Legion
35
37
  private
36
38
 
37
39
  def run_memory_migrations
38
- migration_path = File.join(
39
- __dir__,
40
- '../lib/legion/extensions/memory/local_migrations'
40
+ migration_path = File.expand_path(
41
+ '../../../../../../lib/legion/extensions/agentic/memory/trace/local_migrations',
42
+ __dir__
41
43
  )
42
44
  ::Sequel::TimestampMigrator.new(@connection, migration_path).run
43
45
  end
@@ -159,6 +161,51 @@ RSpec.describe 'lex-memory local SQLite persistence' do
159
161
  expect(second_count).to eq(first_count)
160
162
  end
161
163
 
164
+ it 'does not rewrite association rows when only trace fields change' do
165
+ store.store(semantic_trace)
166
+ store.store(episodic_trace)
167
+
168
+ threshold = Legion::Extensions::Agentic::Memory::Trace::Helpers::Trace::COACTIVATION_THRESHOLD
169
+ threshold.times { store.record_coactivation(semantic_trace[:trace_id], episodic_trace[:trace_id]) }
170
+ store.save_to_local
171
+
172
+ io = StringIO.new
173
+ logger = Logger.new(io)
174
+ logger.level = Logger::DEBUG
175
+ Legion::Data::Local.connection.loggers << logger
176
+
177
+ store.traces[semantic_trace[:trace_id]][:strength] = 0.77
178
+ store.save_to_local
179
+
180
+ sql = io.string
181
+ expect(sql).not_to match(/DELETE FROM [`"]memory_associations[`"]/)
182
+ expect(sql).not_to match(/INSERT INTO [`"]memory_associations[`"]/)
183
+ ensure
184
+ Legion::Data::Local.connection.loggers.delete(logger) if logger
185
+ end
186
+
187
+ it 'only deletes stale rows for the current partition' do
188
+ other_partition = Legion::Extensions::Agentic::Memory::Trace::Helpers::Store.new(partition_id: 'agent-2')
189
+ agent_two_trace = trace_helper.new_trace(
190
+ type: :episodic,
191
+ content_payload: { event: 'agent 2 boot' },
192
+ domain_tags: ['boot'],
193
+ partition_id: 'agent-2'
194
+ )
195
+
196
+ store.store(semantic_trace)
197
+ other_partition.store(agent_two_trace)
198
+ other_partition.save_to_local
199
+ store.save_to_local
200
+
201
+ store.delete(semantic_trace[:trace_id])
202
+ store.save_to_local
203
+
204
+ db = Legion::Data::Local.connection
205
+ expect(db[:memory_traces].where(partition_id: 'agent-2').count).to eq(1)
206
+ expect(db[:memory_traces].where(trace_id: agent_two_trace[:trace_id]).count).to eq(1)
207
+ end
208
+
162
209
  it 'is a no-op when Local is not connected' do
163
210
  allow(Legion::Data::Local).to receive(:connected?).and_return(false)
164
211
  expect { store.save_to_local }.not_to raise_error
@@ -189,6 +236,29 @@ RSpec.describe 'lex-memory local SQLite persistence' do
189
236
  expect(fresh.associations[semantic_trace[:trace_id]]).not_to be_empty
190
237
  end
191
238
 
239
+ it 'loads only traces for the requested partition' do
240
+ other_partition = Legion::Extensions::Agentic::Memory::Trace::Helpers::Store.new(partition_id: 'agent-2')
241
+ agent_two_trace = trace_helper.new_trace(
242
+ type: :episodic,
243
+ content_payload: { event: 'agent 2 boot' },
244
+ domain_tags: ['boot'],
245
+ partition_id: 'agent-2'
246
+ )
247
+
248
+ store.store(semantic_trace)
249
+ other_partition.store(agent_two_trace)
250
+ store.save_to_local
251
+ other_partition.save_to_local
252
+
253
+ fresh = Legion::Extensions::Agentic::Memory::Trace::Helpers::Store.new(partition_id: 'default')
254
+ other = Legion::Extensions::Agentic::Memory::Trace::Helpers::Store.new(partition_id: 'agent-2')
255
+
256
+ expect(fresh.get(semantic_trace[:trace_id])).not_to be_nil
257
+ expect(fresh.get(agent_two_trace[:trace_id])).to be_nil
258
+ expect(other.get(agent_two_trace[:trace_id])).not_to be_nil
259
+ expect(other.get(semantic_trace[:trace_id])).to be_nil
260
+ end
261
+
192
262
  it 'is a no-op when Local is not connected' do
193
263
  allow(Legion::Data::Local).to receive(:connected?).and_return(false)
194
264
  expect { store.load_from_local }.not_to raise_error
@@ -8,4 +8,31 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace do
8
8
  it 'has a version that is a string' do
9
9
  expect(Legion::Extensions::Agentic::Memory::Trace::VERSION).to be_a(String)
10
10
  end
11
+
12
+ describe '.shared_store' do
13
+ before { described_class.reset_store! }
14
+ after { described_class.reset_store! }
15
+
16
+ it 'prefers the agent-local store when local persistence is available' do
17
+ allow(described_class).to receive(:local_store_available?).and_return(true)
18
+ allow(described_class).to receive(:configured_trace_store).and_return(nil)
19
+ allow(described_class).to receive(:resolve_agent_id).and_return('agent-1')
20
+ allow(described_class::Helpers::Store).to receive(:new).with(partition_id: 'agent-1').and_return(:local_store)
21
+
22
+ expect(described_class.shared_store).to eq(:local_store)
23
+ end
24
+
25
+ it 'uses the shared Postgres store when trace_store is explicitly shared' do
26
+ allow(described_class).to receive(:local_store_available?).and_return(true)
27
+ allow(described_class).to receive(:configured_trace_store).and_return(:shared)
28
+ allow(described_class).to receive(:postgres_available?).and_return(true)
29
+ allow(described_class).to receive(:resolve_agent_id).and_return('agent-1')
30
+ allow(described_class).to receive(:resolve_tenant_id).and_return('tenant-1')
31
+ allow(described_class::Helpers::PostgresStore).to receive(:new)
32
+ .with(tenant_id: 'tenant-1', agent_id: 'agent-1')
33
+ .and_return(:shared_store)
34
+
35
+ expect(described_class.shared_store).to eq(:shared_store)
36
+ end
37
+ end
11
38
  end
@@ -3,6 +3,8 @@
3
3
  require 'legion/extensions/agentic/memory/trace/client'
4
4
 
5
5
  RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Runners::Consolidation do
6
+ before(:each) { Legion::Extensions::Agentic::Memory::Trace.reset_store! }
7
+
6
8
  let(:client) { Legion::Extensions::Agentic::Memory::Trace::Client.new }
7
9
 
8
10
  describe '#reinforce' do
@@ -51,6 +53,30 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Runners::Consolidatio
51
53
  end
52
54
 
53
55
  describe '#decay_cycle' do
56
+ it 'defers Gaia heartbeat decay work to the background actor when maintenance is false' do
57
+ client.store_trace(type: :semantic, content_payload: {})
58
+
59
+ result = client.decay_cycle(maintenance: false)
60
+ expect(result).to include(
61
+ decayed: 0,
62
+ pruned: 0,
63
+ total: 1,
64
+ remaining: 1,
65
+ deferred: true,
66
+ reason: :background_decay_actor
67
+ )
68
+ end
69
+
70
+ it 'reuses the latest maintenance summary when decay is deferred' do
71
+ client.store_trace(type: :semantic, content_payload: {})
72
+ client.decay_cycle(tick_count: 100)
73
+
74
+ result = client.decay_cycle(maintenance: false)
75
+ expect(result[:deferred]).to be true
76
+ expect(result[:total]).to be >= result[:remaining]
77
+ expect(result[:maintained_at]).not_to be_nil
78
+ end
79
+
54
80
  it 'decays non-firmware traces' do
55
81
  client.store_trace(type: :semantic, content_payload: {})
56
82
  client.store_trace(type: :firmware, content_payload: { directive_text: 'protect' })
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.20
4
+ version: 0.1.22
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -139,44 +139,44 @@ dependencies:
139
139
  name: rubocop
140
140
  requirement: !ruby/object:Gem::Requirement
141
141
  requirements:
142
- - - "~>"
142
+ - - ">="
143
143
  - !ruby/object:Gem::Version
144
- version: '1.60'
144
+ version: '0'
145
145
  type: :development
146
146
  prerelease: false
147
147
  version_requirements: !ruby/object:Gem::Requirement
148
148
  requirements:
149
- - - "~>"
149
+ - - ">="
150
150
  - !ruby/object:Gem::Version
151
- version: '1.60'
151
+ version: '0'
152
152
  - !ruby/object:Gem::Dependency
153
153
  name: rubocop-legion
154
154
  requirement: !ruby/object:Gem::Requirement
155
155
  requirements:
156
- - - "~>"
156
+ - - ">="
157
157
  - !ruby/object:Gem::Version
158
- version: '0.1'
158
+ version: '0'
159
159
  type: :development
160
160
  prerelease: false
161
161
  version_requirements: !ruby/object:Gem::Requirement
162
162
  requirements:
163
- - - "~>"
163
+ - - ">="
164
164
  - !ruby/object:Gem::Version
165
- version: '0.1'
165
+ version: '0'
166
166
  - !ruby/object:Gem::Dependency
167
167
  name: rubocop-rspec
168
168
  requirement: !ruby/object:Gem::Requirement
169
169
  requirements:
170
- - - "~>"
170
+ - - ">="
171
171
  - !ruby/object:Gem::Version
172
- version: '2.26'
172
+ version: '0'
173
173
  type: :development
174
174
  prerelease: false
175
175
  version_requirements: !ruby/object:Gem::Requirement
176
176
  requirements:
177
- - - "~>"
177
+ - - ">="
178
178
  - !ruby/object:Gem::Version
179
- version: '2.26'
179
+ version: '0'
180
180
  description: 'LEX agentic memory domain: episodic, semantic, and working memory'
181
181
  email:
182
182
  - matthewdiverson@gmail.com