lex-agentic-memory 0.1.28 → 0.1.31

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: a4a726ce7792c2342e46df371973c90e78d11957ec56c76f4ab6c2f7c8c31b7a
4
- data.tar.gz: 2fab727488f18d34881ff4805dcf5452b4a2fd3bde78a5924f5a638f4440b1c1
3
+ metadata.gz: d9ec98fbb5ad987564ae50bc87572aa2d23eb3c0011dfa2c5c2a000926286eed
4
+ data.tar.gz: 318bae8b3abd84f2ec9e1b8101a0aed4e4f8656822356d1a9ef65557984234e9
5
5
  SHA512:
6
- metadata.gz: ca13a314ea5c7eb0895bb98e36da27fd53992a3e66fd9b6e4da0e257a24e532fbd69be957a771d1f84295178b1e5b8fce20d18ec8a2289390474018714956e0d
7
- data.tar.gz: ab7fe39c52cd26ac67567583f0f7c5ffa38cf16f8c0e8def6daeb3d3f93a22363dc0a8f130c8e777fa0545637ea234533a98a57f495cb1fd874b95767ed5e1de
6
+ metadata.gz: be83bf5dd3d3d283f00ab26f16f2159f01d12bb0e2f561d956ff6039902baacb5cbce96d61fb2becfd34b83f282daa0f63b7581770d204728629d088dda6cfcf
7
+ data.tar.gz: 36af01ed5dc06602f8233667623f53f067f11a19ef01b350f1bd8d6aaf08af4ff44dbde0987a10add8b015dd14115a74005a04e237b527e83076979a42743bb5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.31] - 2026-04-27
4
+ ### Added
5
+ - Add a heuristic pre-compaction memory save path and synchronous pre-compaction event listeners for `chat.pre_compact`, `context.pre_compact`, and `conversation.pre_compact`.
6
+
7
+ ### Fixed
8
+ - Require `legion/logging` and `legion/cache/helper` before `CacheStore` includes helper APIs, preventing `log` or cache helper methods from being skipped when the file is loaded directly.
9
+ - Pass `CacheStore` TTL values through `cache_set` as keywords so flushes work with the shared cache helper API.
10
+
11
+ ## [0.1.30] - 2026-04-27
12
+ ### Fixed
13
+ - Local trace persistence now writes only changed trace rows instead of upserting every trace in the partition for a one-trace update.
14
+ - Local association persistence now stores `partition_id` and restores associations by partition, avoiding a startup query with one giant trace-id `IN (...)` list.
15
+
16
+ ## [0.1.29] - 2026-04-22
17
+ ### Fixed
18
+ - `parse_db_content` no longer attempts JSON parse on plain-text content — checks for `{`/`[` prefix before parsing, returns raw string for non-JSON content without logging errors
19
+
3
20
  ## [0.1.28] - 2026-04-22
4
21
  ### Fixed
5
22
  - Snapshot save/restore now logs warnings when Self/Affect module methods are unavailable instead of silently skipping
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Agentic
6
+ module Memory
7
+ module Consolidation
8
+ module Helpers
9
+ module Extractor
10
+ CATEGORY_PATTERNS = {
11
+ decisions: /\b(decided|decision|went with|choose|chosen|locked in|settled on)\b/i,
12
+ preferences: /\b(always|never|prefer|preference|use .* by default|do not|don't)\b/i,
13
+ milestones: /\b(worked|fixed|green|merged|shipped|completed|breakthrough|finally)\b/i,
14
+ problems: /\b(bug|failed|failing|failure|broken|error|crash|root cause|regression|blocked)\b/i,
15
+ discoveries: /\b(found|learned|discovered|turns out|confirmed|observed)\b/i
16
+ }.freeze
17
+
18
+ module_function
19
+
20
+ def extract(transcript)
21
+ lines = transcript_lines(transcript)
22
+ CATEGORY_PATTERNS.each_with_object({}) do |(category, pattern), summary|
23
+ summary[category] = lines.grep(pattern).uniq
24
+ end
25
+ end
26
+
27
+ def transcript_lines(transcript)
28
+ text = transcript_text(transcript)
29
+ text.split(/[\r\n]+|(?<=[.!?])\s+/)
30
+ .map { |line| line.strip.gsub(/\s+/, ' ') }
31
+ .reject(&:empty?)
32
+ end
33
+
34
+ def transcript_text(transcript)
35
+ case transcript
36
+ when String
37
+ transcript
38
+ when Array
39
+ transcript.map { |entry| entry_text(entry) }.join("\n")
40
+ else
41
+ session_messages(transcript).map { |entry| entry_text(entry) }.join("\n")
42
+ end
43
+ end
44
+
45
+ def session_messages(session)
46
+ return [] unless session
47
+ return session.transcript if session.respond_to?(:transcript)
48
+ return session.messages if session.respond_to?(:messages)
49
+ return session.chat.messages if session.respond_to?(:chat) && session.chat.respond_to?(:messages)
50
+
51
+ []
52
+ end
53
+
54
+ def entry_text(entry)
55
+ return entry if entry.is_a?(String)
56
+
57
+ if entry.respond_to?(:to_h)
58
+ hash = entry.to_h
59
+ return hash[:content] || hash['content'] || hash[:text] || hash['text'] || hash.to_s
60
+ end
61
+
62
+ entry.respond_to?(:content) ? entry.content.to_s : entry.to_s
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'time'
5
+ require 'legion/logging'
6
+ require_relative '../trace'
7
+ require_relative 'helpers/extractor'
8
+
9
+ module Legion
10
+ module Extensions
11
+ module Agentic
12
+ module Memory
13
+ module Consolidation
14
+ module PreCompact
15
+ class << self
16
+ include Legion::Logging::Helper if defined?(Legion::Logging::Helper)
17
+
18
+ def before_compact(session:, agent_id: nil, store: nil, apollo: nil)
19
+ summary = Helpers::Extractor.extract(session)
20
+ saved = persist_summary(summary, session: session, agent_id: agent_id, store: store)
21
+ promote_summary(summary, agent_id: agent_id, apollo: apollo)
22
+
23
+ { success: true, agent_id: resolved_agent_id(agent_id), saved: saved, summary: summary }
24
+ rescue StandardError => e
25
+ log.warn("[memory] pre_compact save failed: #{e.message}")
26
+ { success: false, reason: :error, message: e.message }
27
+ end
28
+
29
+ private
30
+
31
+ def persist_summary(summary, session:, agent_id:, store:)
32
+ memory_store = store || Trace.shared_store
33
+ return 0 unless memory_store.respond_to?(:store)
34
+
35
+ saved = 0
36
+ summary.each do |category, entries|
37
+ entries.each do |entry|
38
+ memory_store.store(trace_for(category, entry, session: session, agent_id: agent_id))
39
+ saved += 1
40
+ end
41
+ end
42
+ memory_store.flush if memory_store.respond_to?(:flush)
43
+ saved
44
+ end
45
+
46
+ def promote_summary(summary, agent_id:, apollo:)
47
+ writer = apollo || (Legion::Apollo if defined?(Legion::Apollo))
48
+ return unless writer.respond_to?(:ingest)
49
+
50
+ summary.each do |category, entries|
51
+ entries.each do |entry|
52
+ writer.ingest(
53
+ content: entry,
54
+ tags: ['pre_compact', category.to_s, "agent:#{resolved_agent_id(agent_id)}"],
55
+ source_channel: 'memory_pre_compact'
56
+ )
57
+ end
58
+ end
59
+ rescue StandardError => e
60
+ log.warn("[memory] pre_compact Apollo promotion failed: #{e.message}")
61
+ end
62
+
63
+ def trace_for(category, entry, session:, agent_id:)
64
+ Trace::Helpers::Trace.new_trace(
65
+ type: :semantic,
66
+ content_payload: {
67
+ category: category,
68
+ text: entry,
69
+ session_id: session_id(session),
70
+ saved_at: Time.now.utc.iso8601
71
+ },
72
+ domain_tags: ['pre_compact', category.to_s],
73
+ source_agent_id: resolved_agent_id(agent_id),
74
+ partition_id: resolved_agent_id(agent_id)
75
+ )
76
+ end
77
+
78
+ def session_id(session)
79
+ return session.id if session.respond_to?(:id)
80
+ return session[:id] if session.is_a?(Hash) && session.key?(:id)
81
+ return session['id'] if session.is_a?(Hash)
82
+
83
+ SecureRandom.uuid
84
+ end
85
+
86
+ def resolved_agent_id(agent_id)
87
+ agent_id || (Legion::Settings.dig(:agent, :id) if defined?(Legion::Settings)) || 'default'
88
+ rescue StandardError => e
89
+ log.warn("[memory] pre_compact agent id resolution failed: #{e.message}")
90
+ 'default'
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'consolidation/helpers/extractor'
4
+ require_relative 'consolidation/pre_compact'
5
+
6
+ module Legion
7
+ module Extensions
8
+ module Agentic
9
+ module Memory
10
+ module Consolidation
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/logging'
4
+ require 'legion/cache/helper'
5
+
3
6
  module Legion
4
7
  module Extensions
5
8
  module Agentic
@@ -12,6 +15,7 @@ module Legion
12
15
  # Keeps a local in-memory copy for fast reads; syncs to cache on flush.
13
16
  class CacheStore
14
17
  include Legion::Logging::Helper if defined?(Legion::Logging::Helper)
18
+ include Legion::Cache::Helper if defined?(Legion::Cache::Helper)
15
19
 
16
20
  TRACE_PREFIX = 'legion:memory:trace:'
17
21
  INDEX_KEY = 'legion:memory:trace_index'
@@ -194,7 +198,7 @@ module Legion
194
198
  @dirty_ids.each_slice(FLUSH_BATCH) do |batch|
195
199
  batch.each do |id|
196
200
  trace = @traces[id]
197
- cache_set(trace_key(id), trace, TTL) if trace
201
+ cache_set(trace_key(id), trace, ttl: TTL) if trace
198
202
  end
199
203
  end
200
204
  end
@@ -208,7 +212,7 @@ module Legion
208
212
  def flush_associations
209
213
  return unless @assoc_dirty
210
214
 
211
- cache_set(ASSOC_KEY, strip_default_procs(@associations), TTL)
215
+ cache_set(ASSOC_KEY, strip_default_procs(@associations), ttl: TTL)
212
216
  @assoc_dirty = false
213
217
  rescue StandardError => e
214
218
  log.warn("[memory] CacheStore flush_associations failed (#{@associations.size} entries): #{e.message}")
@@ -217,7 +221,7 @@ module Legion
217
221
  def flush_index
218
222
  return if @dirty_ids.empty? && @deleted_ids.empty?
219
223
 
220
- cache_set(INDEX_KEY, @traces.keys, TTL)
224
+ cache_set(INDEX_KEY, @traces.keys, ttl: TTL)
221
225
  rescue StandardError => e
222
226
  log.warn("[memory] CacheStore flush_index failed (#{@traces.size} traces): #{e.message}")
223
227
  end
@@ -13,6 +13,8 @@ module Legion
13
13
  class Store
14
14
  include Legion::Logging::Helper if defined?(Legion::Logging::Helper)
15
15
 
16
+ ASSOCIATION_LOAD_BATCH_SIZE = 500
17
+
16
18
  attr_reader :traces, :associations
17
19
 
18
20
  def initialize(partition_id: nil)
@@ -166,49 +168,61 @@ module Legion
166
168
  snapshots = snapshot_dirty_state
167
169
  return unless snapshots
168
170
 
169
- traces_snapshot, associations_snapshot, trace_rows_snapshot, traces_dirty, associations_dirty = snapshots
171
+ traces_snapshot, associations_snapshot, trace_rows_snapshot, trace_changes, associations_dirty = snapshots
170
172
  db.transaction do
171
173
  scoped_trace_ids = db[:memory_traces].where(partition_id: @partition_id).select_map(:trace_id)
172
174
  memory_trace_ids = traces_snapshot.keys
173
- stale_ids = persist_dirty_traces(db, trace_rows_snapshot, scoped_trace_ids, memory_trace_ids, traces_dirty)
175
+ stale_ids = scoped_trace_ids - memory_trace_ids
176
+ persist_dirty_traces(db, trace_rows_snapshot, trace_changes, stale_ids)
174
177
  persist_dirty_associations(db, associations_snapshot, scoped_trace_ids, memory_trace_ids, stale_ids, associations_dirty)
175
178
  end
176
179
  clear_dirty_flags(trace_rows_snapshot)
177
180
  end
178
181
 
179
182
  def snapshot_dirty_state
180
- traces_snapshot, associations_snapshot, trace_rows_snapshot, traces_dirty, associations_dirty = @mutex.synchronize do
183
+ traces_snapshot, associations_snapshot, trace_rows_snapshot, trace_changes, associations_dirty = @mutex.synchronize do
181
184
  ts = @traces.transform_values(&:dup)
182
185
  as = @associations.each_with_object({}) { |(tid, targets), memo| memo[tid] = targets.dup }
183
186
  trs = ts.transform_values { |trace| serialize_trace_for_db(trace) }
184
- [ts, as, trs, @traces_dirty || trs != @persisted_trace_rows, @associations_dirty]
187
+ changed_trace_ids = trs.each_key.reject { |trace_id| trs[trace_id] == @persisted_trace_rows[trace_id] }
188
+ trace_changes = { dirty: @traces_dirty || changed_trace_ids.any?, changed_ids: changed_trace_ids }
189
+ [ts, as, trs, trace_changes, @associations_dirty]
185
190
  end
186
- return nil unless traces_dirty || associations_dirty
191
+ return nil unless trace_changes[:dirty] || associations_dirty
187
192
 
188
- [traces_snapshot, associations_snapshot, trace_rows_snapshot, traces_dirty, associations_dirty]
193
+ [traces_snapshot, associations_snapshot, trace_rows_snapshot, trace_changes, associations_dirty]
189
194
  end
190
195
 
191
- def persist_dirty_traces(db, trace_rows_snapshot, scoped_trace_ids, memory_trace_ids, traces_dirty)
192
- return [] unless traces_dirty
196
+ def persist_dirty_traces(db, trace_rows_snapshot, trace_changes, stale_ids)
197
+ return unless trace_changes[:dirty] || !stale_ids.empty?
193
198
 
194
199
  ds = db[:memory_traces]
195
- trace_rows_snapshot.each_value do |row|
196
- ds.insert_conflict(:replace).insert(row)
200
+ trace_changes[:changed_ids].each do |trace_id|
201
+ ds.insert_conflict(:replace).insert(trace_rows_snapshot.fetch(trace_id))
197
202
  end
198
- stale_ids = scoped_trace_ids - memory_trace_ids
199
203
  db[:memory_traces].where(trace_id: stale_ids).delete unless stale_ids.empty?
200
- stale_ids
201
204
  end
202
205
 
203
206
  def persist_dirty_associations(db, associations_snapshot, scoped_trace_ids, memory_trace_ids, stale_ids, dirty)
204
207
  assoc_scope_ids = (scoped_trace_ids + memory_trace_ids).uniq
205
208
  return unless (dirty || !stale_ids.empty?) && !assoc_scope_ids.empty?
206
209
 
207
- db[:memory_associations].where(trace_id_a: assoc_scope_ids).delete
208
- db[:memory_associations].where(trace_id_b: assoc_scope_ids).delete
210
+ association_columns = db[:memory_associations].columns
211
+ partitioned_associations = association_columns.include?(:partition_id)
212
+ if partitioned_associations
213
+ db[:memory_associations].where(partition_id: @partition_id).delete
214
+ else
215
+ assoc_scope_ids.each_slice(ASSOCIATION_LOAD_BATCH_SIZE) do |ids|
216
+ db[:memory_associations].where(trace_id_a: ids).delete
217
+ db[:memory_associations].where(trace_id_b: ids).delete
218
+ end
219
+ end
220
+
209
221
  associations_snapshot.each do |id_a, targets|
210
222
  targets.each do |id_b, count|
211
- db[:memory_associations].insert(trace_id_a: id_a, trace_id_b: id_b, coactivation_count: count)
223
+ row = { trace_id_a: id_a, trace_id_b: id_b, coactivation_count: count }
224
+ row[:partition_id] = @partition_id if partitioned_associations
225
+ db[:memory_associations].insert(row)
212
226
  end
213
227
  end
214
228
  end
@@ -231,13 +245,7 @@ module Legion
231
245
  @traces[row[:trace_id]] = deserialize_trace_from_db(row)
232
246
  end
233
247
 
234
- trace_ids = @traces.keys
235
- unless trace_ids.empty?
236
- db[:memory_associations].where(trace_id_a: trace_ids).each do |row|
237
- @associations[row[:trace_id_a]] ||= {}
238
- @associations[row[:trace_id_a]][row[:trace_id_b]] = row[:coactivation_count]
239
- end
240
- end
248
+ load_local_associations(db)
241
249
 
242
250
  @persisted_trace_rows = @traces.transform_values { |trace| serialize_trace_for_db(trace) }
243
251
  @traces_dirty = false
@@ -246,6 +254,24 @@ module Legion
246
254
 
247
255
  private
248
256
 
257
+ def load_local_associations(db)
258
+ association_columns = db[:memory_associations].columns
259
+ if association_columns.include?(:partition_id)
260
+ db[:memory_associations].where(partition_id: @partition_id).each { |row| load_association_row(row) }
261
+ else
262
+ @traces.keys.each_slice(ASSOCIATION_LOAD_BATCH_SIZE) do |trace_ids|
263
+ db[:memory_associations].where(trace_id_a: trace_ids).each { |row| load_association_row(row) }
264
+ end
265
+ end
266
+ end
267
+
268
+ def load_association_row(row)
269
+ return unless @traces.key?(row[:trace_id_a])
270
+
271
+ @associations[row[:trace_id_a]] ||= {}
272
+ @associations[row[:trace_id_a]][row[:trace_id_b]] = row[:coactivation_count]
273
+ end
274
+
249
275
  def resolve_partition_id
250
276
  Legion::Settings.dig(:agent, :id) || 'default'
251
277
  rescue StandardError => e
@@ -316,10 +342,15 @@ module Legion
316
342
  end
317
343
 
318
344
  def parse_db_content(raw)
319
- parsed = Legion::JSON.load(raw.to_s)
320
- parsed.is_a?(Hash) ? parsed : raw
345
+ return raw unless raw.is_a?(String)
346
+
347
+ stripped = raw.strip
348
+ return raw unless stripped.start_with?('{', '[')
349
+
350
+ parsed = Legion::JSON.load(stripped)
351
+ parsed.is_a?(Hash) || parsed.is_a?(Array) ? parsed : raw
321
352
  rescue StandardError => e
322
- log.error "[trace_persistence] deserialize_trace_from_db content: #{e.message}"
353
+ log.debug "[trace_persistence] malformed JSON in content column, returning raw: #{e.message}"
323
354
  raw
324
355
  end
325
356
 
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ up do
5
+ alter_table(:memory_associations) do
6
+ add_column :partition_id, String
7
+ add_index :partition_id
8
+ end
9
+
10
+ run <<~SQL
11
+ UPDATE memory_associations
12
+ SET partition_id = (
13
+ SELECT memory_traces.partition_id
14
+ FROM memory_traces
15
+ WHERE memory_traces.trace_id = memory_associations.trace_id_a
16
+ )
17
+ WHERE partition_id IS NULL
18
+ SQL
19
+ end
20
+
21
+ down do
22
+ alter_table(:memory_associations) do
23
+ drop_index :partition_id
24
+ drop_column :partition_id
25
+ end
26
+ end
27
+ end
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Agentic
6
6
  module Memory
7
- VERSION = '0.1.28'
7
+ VERSION = '0.1.31'
8
8
  end
9
9
  end
10
10
  end
@@ -12,6 +12,7 @@ require_relative 'memory/echo'
12
12
  require_relative 'memory/echo_chamber'
13
13
  require_relative 'memory/immune_memory'
14
14
  require_relative 'memory/reserve'
15
+ require_relative 'memory/consolidation'
15
16
  require_relative 'memory/trace'
16
17
  require_relative 'memory/episodic'
17
18
  require_relative 'memory/semantic'
@@ -42,6 +43,12 @@ module Legion
42
43
  def self.transport_required?
43
44
  false
44
45
  end
46
+
47
+ def self.handle_pre_compact_event(event)
48
+ agent_id = event[:agent_id] || event[:agent]
49
+ session = event[:session] || event[:transcript] || event[:messages]
50
+ Consolidation::PreCompact.before_compact(session: session, agent_id: agent_id)
51
+ end
45
52
  end
46
53
  end
47
54
  end
@@ -70,4 +77,11 @@ if defined?(Legion::Events)
70
77
  Legion::Extensions::Agentic::Memory::Trace::Helpers::Snapshot.restore_snapshot(agent_id: agent_id)
71
78
  end
72
79
  end
80
+
81
+ pre_compact_enabled = settings_loaded ? Legion::Settings.dig(:memory, :pre_compact, :enabled) != false : true
82
+ if pre_compact_enabled
83
+ %w[chat.pre_compact context.pre_compact conversation.pre_compact].each do |event_name|
84
+ Legion::Events.on(event_name) { |event| Legion::Extensions::Agentic::Memory.handle_pre_compact_event(event) }
85
+ end
86
+ end
73
87
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Agentic::Memory::Consolidation::Helpers::Extractor do
4
+ it 'extracts heuristic categories from chat-like messages' do
5
+ summary = described_class.extract([
6
+ { content: 'We went with SQLite because it is local.' },
7
+ { content: 'Never send embeddings to vLLM.' },
8
+ { content: 'The failing query was fixed after adding partition_id.' },
9
+ { content: 'Found the association load root cause.' }
10
+ ])
11
+
12
+ expect(summary[:decisions]).to include('We went with SQLite because it is local.')
13
+ expect(summary[:preferences]).to include('Never send embeddings to vLLM.')
14
+ expect(summary[:milestones]).to include('The failing query was fixed after adding partition_id.')
15
+ expect(summary[:problems]).to include('The failing query was fixed after adding partition_id.')
16
+ expect(summary[:discoveries]).to include('Found the association load root cause.')
17
+ end
18
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Agentic::Memory::Consolidation::PreCompact do
4
+ let(:store) do
5
+ Class.new do
6
+ attr_reader :traces, :flushed
7
+
8
+ def initialize
9
+ @traces = []
10
+ @flushed = false
11
+ end
12
+
13
+ def store(trace)
14
+ @traces << trace
15
+ end
16
+
17
+ def flush
18
+ @flushed = true
19
+ end
20
+ end.new
21
+ end
22
+
23
+ let(:session) do
24
+ [
25
+ { role: :user, content: 'We decided to use local memory because vLLM was failing.' },
26
+ { role: :assistant, content: 'I found the root cause and fixed the startup crash.' },
27
+ { role: :user, content: 'Always keep embeddings local.' }
28
+ ]
29
+ end
30
+
31
+ it 'extracts high-signal compaction details and stores them as traces' do
32
+ result = described_class.before_compact(session: session, agent_id: 'agent-1', store: store)
33
+
34
+ expect(result[:success]).to be true
35
+ expect(result[:saved]).to be >= 3
36
+ expect(store.flushed).to be true
37
+ expect(store.traces.map { |trace| trace[:partition_id] }.uniq).to eq(['agent-1'])
38
+ expect(store.traces.flat_map { |trace| trace[:domain_tags] }).to include('pre_compact', 'decisions', 'preferences', 'problems')
39
+ end
40
+
41
+ it 'promotes extracted entries to Apollo when an ingest writer is provided' do
42
+ apollo = class_double('Apollo', ingest: true)
43
+
44
+ described_class.before_compact(session: session, agent_id: 'agent-1', store: store, apollo: apollo)
45
+
46
+ expect(apollo).to have_received(:ingest).at_least(:once)
47
+ end
48
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::CacheStore do
4
+ describe '#initialize' do
5
+ it 'has logging and cache helpers when loaded directly' do
6
+ allow(Legion::Cache).to receive(:get).and_return(nil)
7
+
8
+ store = described_class.new
9
+
10
+ expect(store).to respond_to(:log)
11
+ expect(store).to respond_to(:cache_get)
12
+ end
13
+ end
14
+
15
+ describe '#flush' do
16
+ it 'passes cache TTL as a keyword argument' do
17
+ allow(Legion::Cache).to receive(:get).and_return(nil)
18
+ allow(Legion::Cache).to receive(:set).and_return(true)
19
+ store = described_class.new
20
+ trace = Legion::Extensions::Agentic::Memory::Trace::Helpers::Trace.new_trace(
21
+ type: :semantic,
22
+ content_payload: { fact: 'cache flush' }
23
+ )
24
+
25
+ store.store(trace)
26
+ store.flush
27
+
28
+ expect(Legion::Cache).to have_received(:set).with(
29
+ a_string_including(trace[:trace_id]),
30
+ trace,
31
+ ttl: described_class::TTL,
32
+ async: false,
33
+ phi: false
34
+ )
35
+ end
36
+ end
37
+ end
@@ -131,6 +131,31 @@ RSpec.describe 'lex-memory local SQLite persistence' do
131
131
  expect(db[:memory_traces].count).to eq(1)
132
132
  end
133
133
 
134
+ it 'persists only the changed trace row on subsequent saves' do
135
+ traces = Array.new(3) do |index|
136
+ trace_helper.new_trace(
137
+ type: :semantic,
138
+ content_payload: { fact: "trace #{index}" },
139
+ domain_tags: ['storage']
140
+ )
141
+ end
142
+ traces.each { |trace| store.store(trace) }
143
+ store.save_to_local
144
+
145
+ io = StringIO.new
146
+ logger = Logger.new(io)
147
+ logger.level = Logger::DEBUG
148
+ Legion::Data::Local.connection.loggers << logger
149
+
150
+ store.traces[traces.first[:trace_id]][:strength] = 0.42
151
+ store.save_to_local
152
+
153
+ trace_writes = io.string.scan(/INSERT(?: OR REPLACE)? INTO [`"]memory_traces[`"]/)
154
+ expect(trace_writes.size).to eq(1)
155
+ ensure
156
+ Legion::Data::Local.connection.loggers.delete(logger) if logger
157
+ end
158
+
134
159
  it 'removes pruned traces from the database' do
135
160
  store.store(semantic_trace)
136
161
  store.store(episodic_trace)
@@ -321,5 +346,28 @@ RSpec.describe 'lex-memory local SQLite persistence' do
321
346
  count = fresh.associations[semantic_trace[:trace_id]][episodic_trace[:trace_id]]
322
347
  expect(count).to eq(threshold)
323
348
  end
349
+
350
+ it 'loads associations by partition instead of one trace-id IN list' do
351
+ store.store(semantic_trace)
352
+ store.store(episodic_trace)
353
+
354
+ threshold = Legion::Extensions::Agentic::Memory::Trace::Helpers::Trace::COACTIVATION_THRESHOLD
355
+ threshold.times { store.record_coactivation(semantic_trace[:trace_id], episodic_trace[:trace_id]) }
356
+ store.save_to_local
357
+
358
+ io = StringIO.new
359
+ logger = Logger.new(io)
360
+ logger.level = Logger::DEBUG
361
+ Legion::Data::Local.connection.loggers << logger
362
+
363
+ fresh = Legion::Extensions::Agentic::Memory::Trace::Helpers::Store.new
364
+
365
+ expect(fresh.associations[semantic_trace[:trace_id]][episodic_trace[:trace_id]]).to eq(threshold)
366
+ expect(io.string).to include('memory_associations')
367
+ expect(io.string).to include('partition_id')
368
+ expect(io.string).not_to match(/trace_id_a[`"]? IN/i)
369
+ ensure
370
+ Legion::Data::Local.connection.loggers.delete(logger) if logger
371
+ end
324
372
  end
325
373
  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.28
4
+ version: 0.1.31
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -213,6 +213,9 @@ files:
213
213
  - lib/legion/extensions/agentic/memory/compression/helpers/information_chunk.rb
214
214
  - lib/legion/extensions/agentic/memory/compression/runners/cognitive_compression.rb
215
215
  - lib/legion/extensions/agentic/memory/compression/version.rb
216
+ - lib/legion/extensions/agentic/memory/consolidation.rb
217
+ - lib/legion/extensions/agentic/memory/consolidation/helpers/extractor.rb
218
+ - lib/legion/extensions/agentic/memory/consolidation/pre_compact.rb
216
219
  - lib/legion/extensions/agentic/memory/echo.rb
217
220
  - lib/legion/extensions/agentic/memory/echo/actors/decay.rb
218
221
  - lib/legion/extensions/agentic/memory/echo/client.rb
@@ -348,6 +351,7 @@ files:
348
351
  - lib/legion/extensions/agentic/memory/trace/helpers/trace.rb
349
352
  - lib/legion/extensions/agentic/memory/trace/local_migrations/20260316000001_create_memory_traces.rb
350
353
  - lib/legion/extensions/agentic/memory/trace/local_migrations/20260316000002_create_memory_associations.rb
354
+ - lib/legion/extensions/agentic/memory/trace/local_migrations/20260427000001_add_partition_id_to_memory_associations.rb
351
355
  - lib/legion/extensions/agentic/memory/trace/persistent_store.rb
352
356
  - lib/legion/extensions/agentic/memory/trace/quota.rb
353
357
  - lib/legion/extensions/agentic/memory/trace/runners/consolidation.rb
@@ -377,6 +381,8 @@ files:
377
381
  - spec/legion/extensions/agentic/memory/compression/helpers/constants_spec.rb
378
382
  - spec/legion/extensions/agentic/memory/compression/helpers/information_chunk_spec.rb
379
383
  - spec/legion/extensions/agentic/memory/compression/runners/cognitive_compression_spec.rb
384
+ - spec/legion/extensions/agentic/memory/consolidation/helpers/extractor_spec.rb
385
+ - spec/legion/extensions/agentic/memory/consolidation/pre_compact_spec.rb
380
386
  - spec/legion/extensions/agentic/memory/echo/actors/decay_spec.rb
381
387
  - spec/legion/extensions/agentic/memory/echo/client_spec.rb
382
388
  - spec/legion/extensions/agentic/memory/echo/cognitive_echo_spec.rb
@@ -469,6 +475,7 @@ files:
469
475
  - spec/legion/extensions/agentic/memory/trace/actors/tier_migration_spec.rb
470
476
  - spec/legion/extensions/agentic/memory/trace/batch_decay_spec.rb
471
477
  - spec/legion/extensions/agentic/memory/trace/client_spec.rb
478
+ - spec/legion/extensions/agentic/memory/trace/helpers/cache_store_spec.rb
472
479
  - spec/legion/extensions/agentic/memory/trace/helpers/decay_spec.rb
473
480
  - spec/legion/extensions/agentic/memory/trace/helpers/error_tracer_spec.rb
474
481
  - spec/legion/extensions/agentic/memory/trace/helpers/hot_tier_spec.rb