lex-agentic-memory 0.1.29 → 0.1.32

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: a0af0290643c859f7d436f39e4b346775fee6e4c361f6d7e7aebc6ac4a248c53
4
- data.tar.gz: 1dbede8a5e1531656c5bad3bcd9621dff10b457fbc18ee21a19a564d39ee455c
3
+ metadata.gz: 07b491ea4b919f623905cf3fe0c651c6a462e46bfe096e06e4221e9eb9e6b801
4
+ data.tar.gz: b92fc5808525806e2dca50c86ea4382583adcbe5b6b9e84d0dd8e1c4add0f14b
5
5
  SHA512:
6
- metadata.gz: 840af0aca9ce3f774be7683ca45ae8ba530863989336cde04bf0bd89b5ce6c0b2722626acec8a69a3ca76415dd40f5cf52ae0f3e8d250e223ed49a45d3d747f9
7
- data.tar.gz: 7311721dc1a95e18f0a50c7cb0693f3e1bcfd067692a91d375a95d1ad60ead085655c4c8060c3b7d2e7a38e290eac22c55f47a784bee72f64ed66978fd8db023
6
+ metadata.gz: ed8eb6cff096080b1435aab249896d4c473d1aab7b11ebec1f687081aa9494bc8671c029d3d7e2e80b4144b705b477e6d58fc796c412d2a6bf75a852fa02f27e
7
+ data.tar.gz: 834d02319dbf0c63722d8bc8e59e5f63d6c18c42c740a71a47ac5f1c6df0c1ad9b983e73b246f751bd24a784aa4e43c5d5f508ed516dbd7f94321fea220f2cf3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.32] - 2026-05-07
4
+ ### Fixed
5
+ - Trace association retrieval now snapshots associated traces under the store mutex before filtering.
6
+ - Local trace restore preserves symbol keys for JSON fields that are consumed as symbol-keyed hashes.
7
+
8
+ ## [0.1.31] - 2026-04-27
9
+ ### Added
10
+ - 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`.
11
+
12
+ ### Fixed
13
+ - 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.
14
+ - Pass `CacheStore` TTL values through `cache_set` as keywords so flushes work with the shared cache helper API.
15
+
16
+ ## [0.1.30] - 2026-04-27
17
+ ### Fixed
18
+ - Local trace persistence now writes only changed trace rows instead of upserting every trace in the partition for a one-trace update.
19
+ - Local association persistence now stores `partition_id` and restores associations by partition, avoiding a startup query with one giant trace-id `IN (...)` list.
20
+
3
21
  ## [0.1.29] - 2026-04-22
4
22
  ### Fixed
5
23
  - `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
@@ -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)
@@ -68,11 +70,14 @@ module Legion
68
70
  end
69
71
 
70
72
  def retrieve_associated(trace_id, min_strength: 0.0, limit: 20)
71
- trace = @traces[trace_id]
72
- return [] unless trace
73
+ associated = @mutex.synchronize do
74
+ trace = @traces[trace_id]
75
+ next [] unless trace
76
+
77
+ trace[:associated_traces].filter_map { |id| @traces[id]&.dup }
78
+ end
73
79
 
74
- trace[:associated_traces]
75
- .filter_map { |id| @traces[id] }
80
+ associated
76
81
  .select { |t| t[:strength] >= min_strength }
77
82
  .sort_by { |t| -t[:strength] }
78
83
  .first(limit)
@@ -166,49 +171,61 @@ module Legion
166
171
  snapshots = snapshot_dirty_state
167
172
  return unless snapshots
168
173
 
169
- traces_snapshot, associations_snapshot, trace_rows_snapshot, traces_dirty, associations_dirty = snapshots
174
+ traces_snapshot, associations_snapshot, trace_rows_snapshot, trace_changes, associations_dirty = snapshots
170
175
  db.transaction do
171
176
  scoped_trace_ids = db[:memory_traces].where(partition_id: @partition_id).select_map(:trace_id)
172
177
  memory_trace_ids = traces_snapshot.keys
173
- stale_ids = persist_dirty_traces(db, trace_rows_snapshot, scoped_trace_ids, memory_trace_ids, traces_dirty)
178
+ stale_ids = scoped_trace_ids - memory_trace_ids
179
+ persist_dirty_traces(db, trace_rows_snapshot, trace_changes, stale_ids)
174
180
  persist_dirty_associations(db, associations_snapshot, scoped_trace_ids, memory_trace_ids, stale_ids, associations_dirty)
175
181
  end
176
182
  clear_dirty_flags(trace_rows_snapshot)
177
183
  end
178
184
 
179
185
  def snapshot_dirty_state
180
- traces_snapshot, associations_snapshot, trace_rows_snapshot, traces_dirty, associations_dirty = @mutex.synchronize do
186
+ traces_snapshot, associations_snapshot, trace_rows_snapshot, trace_changes, associations_dirty = @mutex.synchronize do
181
187
  ts = @traces.transform_values(&:dup)
182
188
  as = @associations.each_with_object({}) { |(tid, targets), memo| memo[tid] = targets.dup }
183
189
  trs = ts.transform_values { |trace| serialize_trace_for_db(trace) }
184
- [ts, as, trs, @traces_dirty || trs != @persisted_trace_rows, @associations_dirty]
190
+ changed_trace_ids = trs.each_key.reject { |trace_id| trs[trace_id] == @persisted_trace_rows[trace_id] }
191
+ trace_changes = { dirty: @traces_dirty || changed_trace_ids.any?, changed_ids: changed_trace_ids }
192
+ [ts, as, trs, trace_changes, @associations_dirty]
185
193
  end
186
- return nil unless traces_dirty || associations_dirty
194
+ return nil unless trace_changes[:dirty] || associations_dirty
187
195
 
188
- [traces_snapshot, associations_snapshot, trace_rows_snapshot, traces_dirty, associations_dirty]
196
+ [traces_snapshot, associations_snapshot, trace_rows_snapshot, trace_changes, associations_dirty]
189
197
  end
190
198
 
191
- def persist_dirty_traces(db, trace_rows_snapshot, scoped_trace_ids, memory_trace_ids, traces_dirty)
192
- return [] unless traces_dirty
199
+ def persist_dirty_traces(db, trace_rows_snapshot, trace_changes, stale_ids)
200
+ return unless trace_changes[:dirty] || !stale_ids.empty?
193
201
 
194
202
  ds = db[:memory_traces]
195
- trace_rows_snapshot.each_value do |row|
196
- ds.insert_conflict(:replace).insert(row)
203
+ trace_changes[:changed_ids].each do |trace_id|
204
+ ds.insert_conflict(:replace).insert(trace_rows_snapshot.fetch(trace_id))
197
205
  end
198
- stale_ids = scoped_trace_ids - memory_trace_ids
199
206
  db[:memory_traces].where(trace_id: stale_ids).delete unless stale_ids.empty?
200
- stale_ids
201
207
  end
202
208
 
203
209
  def persist_dirty_associations(db, associations_snapshot, scoped_trace_ids, memory_trace_ids, stale_ids, dirty)
204
210
  assoc_scope_ids = (scoped_trace_ids + memory_trace_ids).uniq
205
211
  return unless (dirty || !stale_ids.empty?) && !assoc_scope_ids.empty?
206
212
 
207
- db[:memory_associations].where(trace_id_a: assoc_scope_ids).delete
208
- db[:memory_associations].where(trace_id_b: assoc_scope_ids).delete
213
+ association_columns = db[:memory_associations].columns
214
+ partitioned_associations = association_columns.include?(:partition_id)
215
+ if partitioned_associations
216
+ db[:memory_associations].where(partition_id: @partition_id).delete
217
+ else
218
+ assoc_scope_ids.each_slice(ASSOCIATION_LOAD_BATCH_SIZE) do |ids|
219
+ db[:memory_associations].where(trace_id_a: ids).delete
220
+ db[:memory_associations].where(trace_id_b: ids).delete
221
+ end
222
+ end
223
+
209
224
  associations_snapshot.each do |id_a, targets|
210
225
  targets.each do |id_b, count|
211
- db[:memory_associations].insert(trace_id_a: id_a, trace_id_b: id_b, coactivation_count: count)
226
+ row = { trace_id_a: id_a, trace_id_b: id_b, coactivation_count: count }
227
+ row[:partition_id] = @partition_id if partitioned_associations
228
+ db[:memory_associations].insert(row)
212
229
  end
213
230
  end
214
231
  end
@@ -231,13 +248,7 @@ module Legion
231
248
  @traces[row[:trace_id]] = deserialize_trace_from_db(row)
232
249
  end
233
250
 
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
251
+ load_local_associations(db)
241
252
 
242
253
  @persisted_trace_rows = @traces.transform_values { |trace| serialize_trace_for_db(trace) }
243
254
  @traces_dirty = false
@@ -246,6 +257,24 @@ module Legion
246
257
 
247
258
  private
248
259
 
260
+ def load_local_associations(db)
261
+ association_columns = db[:memory_associations].columns
262
+ if association_columns.include?(:partition_id)
263
+ db[:memory_associations].where(partition_id: @partition_id).each { |row| load_association_row(row) }
264
+ else
265
+ @traces.keys.each_slice(ASSOCIATION_LOAD_BATCH_SIZE) do |trace_ids|
266
+ db[:memory_associations].where(trace_id_a: trace_ids).each { |row| load_association_row(row) }
267
+ end
268
+ end
269
+ end
270
+
271
+ def load_association_row(row)
272
+ return unless @traces.key?(row[:trace_id_a])
273
+
274
+ @associations[row[:trace_id_a]] ||= {}
275
+ @associations[row[:trace_id_a]][row[:trace_id_b]] = row[:coactivation_count]
276
+ end
277
+
249
278
  def resolve_partition_id
250
279
  Legion::Settings.dig(:agent, :id) || 'default'
251
280
  rescue StandardError => e
@@ -328,13 +357,27 @@ module Legion
328
357
  raw
329
358
  end
330
359
 
331
- def parse_db_json(raw, field, **_opts, &default)
332
- Legion::JSON.load(raw.to_s)
360
+ def parse_db_json(raw, field, symbolize: false, &default)
361
+ parsed = Legion::JSON.load(raw.to_s)
362
+ symbolize ? symbolize_keys(parsed) : parsed
333
363
  rescue StandardError => e
334
364
  log.error "[trace_persistence] deserialize_trace_from_db #{field}: #{e.message}"
335
365
  default&.call
336
366
  end
337
367
 
368
+ def symbolize_keys(value)
369
+ case value
370
+ when Hash
371
+ value.each_with_object({}) do |(key, nested), memo|
372
+ memo[key.to_sym] = symbolize_keys(nested)
373
+ end
374
+ when Array
375
+ value.map { |nested| symbolize_keys(nested) }
376
+ else
377
+ value
378
+ end
379
+ end
380
+
338
381
  def link_traces(id_a, id_b)
339
382
  trace_a = @traces[id_a]
340
383
  trace_b = @traces[id_b]
@@ -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.29'
7
+ VERSION = '0.1.32'
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
@@ -95,6 +95,18 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::Store do
95
95
  associated = store.retrieve_associated(semantic_trace[:trace_id])
96
96
  expect(associated.size).to eq(0)
97
97
  end
98
+
99
+ it 'snapshots associated traces while holding the store mutex' do
100
+ store.store(semantic_trace)
101
+ store.store(episodic_trace)
102
+ semantic_trace[:associated_traces] << episodic_trace[:trace_id]
103
+
104
+ mutex = store.instance_variable_get(:@mutex)
105
+ expect(mutex).to receive(:synchronize).and_call_original
106
+
107
+ associated = store.retrieve_associated(semantic_trace[:trace_id])
108
+ expect(associated.map { |trace| trace[:trace_id] }).to eq([episodic_trace[:trace_id]])
109
+ end
98
110
  end
99
111
 
100
112
  describe '#all_traces' do
@@ -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)
@@ -224,6 +249,23 @@ RSpec.describe 'lex-memory local SQLite persistence' do
224
249
  expect(fresh.get(episodic_trace[:trace_id])).not_to be_nil
225
250
  end
226
251
 
252
+ it 'honors symbolize parsing for JSON hash fields' do
253
+ trace = trace_helper.new_trace(
254
+ type: :semantic,
255
+ content_payload: { fact: 'symbolized' },
256
+ domain_tags: ['json'],
257
+ emotional_valence: { joy: 0.8 }
258
+ )
259
+ store.store(trace)
260
+ store.save_to_local
261
+
262
+ fresh = Legion::Extensions::Agentic::Memory::Trace::Helpers::Store.new
263
+ restored = fresh.get(trace[:trace_id])
264
+
265
+ expect(restored[:emotional_valence]).to include(joy: 0.8)
266
+ expect(restored[:emotional_valence]).not_to have_key('joy')
267
+ end
268
+
227
269
  it 'restores associations from the database into a fresh store' do
228
270
  store.store(semantic_trace)
229
271
  store.store(episodic_trace)
@@ -321,5 +363,28 @@ RSpec.describe 'lex-memory local SQLite persistence' do
321
363
  count = fresh.associations[semantic_trace[:trace_id]][episodic_trace[:trace_id]]
322
364
  expect(count).to eq(threshold)
323
365
  end
366
+
367
+ it 'loads associations by partition instead of one trace-id IN list' do
368
+ store.store(semantic_trace)
369
+ store.store(episodic_trace)
370
+
371
+ threshold = Legion::Extensions::Agentic::Memory::Trace::Helpers::Trace::COACTIVATION_THRESHOLD
372
+ threshold.times { store.record_coactivation(semantic_trace[:trace_id], episodic_trace[:trace_id]) }
373
+ store.save_to_local
374
+
375
+ io = StringIO.new
376
+ logger = Logger.new(io)
377
+ logger.level = Logger::DEBUG
378
+ Legion::Data::Local.connection.loggers << logger
379
+
380
+ fresh = Legion::Extensions::Agentic::Memory::Trace::Helpers::Store.new
381
+
382
+ expect(fresh.associations[semantic_trace[:trace_id]][episodic_trace[:trace_id]]).to eq(threshold)
383
+ expect(io.string).to include('memory_associations')
384
+ expect(io.string).to include('partition_id')
385
+ expect(io.string).not_to match(/trace_id_a[`"]? IN/i)
386
+ ensure
387
+ Legion::Data::Local.connection.loggers.delete(logger) if logger
388
+ end
324
389
  end
325
390
  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.29
4
+ version: 0.1.32
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