lex-agentic-memory 0.1.21 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f403d34cba75eaf3f800a8852c8d6e16d2f56924b2454b7c444a0cee82a00f04
4
- data.tar.gz: f946558e6cf3103bc32bf44d9accb9a4c3a13d91342e7a06a5f6b5ec98c0c856
3
+ metadata.gz: be817722cc940ed946ce77beb9a7c33b5671ea3bf84d206229cdc507dbe85f0a
4
+ data.tar.gz: 73c42aea2da869ecce1de5c02346320531361fd4d563884d6724cd00ffbdb9c0
5
5
  SHA512:
6
- metadata.gz: e49ff76e259d4237f19a7998f8e973438ebcfceb44322990a6213f35bd07481adfea5f8cd037c351217af569931f2344ceb04b5603bc53cadd5c27d02cec4cbe
7
- data.tar.gz: 28a6d1d21d23f0ba91d034b441ee7e858fc51d1d1662e9445b58ac215e09dd271871f90941121d04566e259d971cf6d615c56412ccf2cd9f08e5658e1dd20f0f
6
+ metadata.gz: f35dce2fc9fd3d2a1fb7e2eb9e22b2656f4b637d24d5c9635dc737075015ef3b38237b570bb279d7a73e33cfeda5b49f8bbd4b75f5bf8b2f1cd2ee6ca74315f7
7
+ data.tar.gz: 381973ab2893d6b5f651e62b16ae6dbfe166e8b949243ca9f8f3cc717fe58d80012e6cca5a14fce9513eb0abac9b58f8d02978c6ce1707b75afdc34eb85b7b76
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
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
+
3
10
  ## [0.1.21] - 2026-04-01
4
11
 
5
12
  ### Changed
@@ -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
@@ -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.21'
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)
@@ -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.21
4
+ version: 0.1.22
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity