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 +4 -4
- data/CHANGELOG.md +18 -0
- data/lib/legion/extensions/agentic/memory/consolidation/helpers/extractor.rb +70 -0
- data/lib/legion/extensions/agentic/memory/consolidation/pre_compact.rb +98 -0
- data/lib/legion/extensions/agentic/memory/consolidation.rb +15 -0
- data/lib/legion/extensions/agentic/memory/trace/helpers/cache_store.rb +7 -3
- data/lib/legion/extensions/agentic/memory/trace/helpers/store.rb +71 -28
- data/lib/legion/extensions/agentic/memory/trace/local_migrations/20260427000001_add_partition_id_to_memory_associations.rb +27 -0
- data/lib/legion/extensions/agentic/memory/version.rb +1 -1
- data/lib/legion/extensions/agentic/memory.rb +14 -0
- data/spec/legion/extensions/agentic/memory/consolidation/helpers/extractor_spec.rb +18 -0
- data/spec/legion/extensions/agentic/memory/consolidation/pre_compact_spec.rb +48 -0
- data/spec/legion/extensions/agentic/memory/trace/helpers/cache_store_spec.rb +37 -0
- data/spec/legion/extensions/agentic/memory/trace/helpers/store_spec.rb +12 -0
- data/spec/legion/extensions/agentic/memory/trace/local_persistence_spec.rb +65 -0
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 07b491ea4b919f623905cf3fe0c651c6a462e46bfe096e06e4221e9eb9e6b801
|
|
4
|
+
data.tar.gz: b92fc5808525806e2dca50c86ea4382583adcbe5b6b9e84d0dd8e1c4add0f14b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
|
194
|
+
return nil unless trace_changes[:dirty] || associations_dirty
|
|
187
195
|
|
|
188
|
-
[traces_snapshot, associations_snapshot, trace_rows_snapshot,
|
|
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,
|
|
192
|
-
return []
|
|
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
|
-
|
|
196
|
-
ds.insert_conflict(:replace).insert(
|
|
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].
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
@@ -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.
|
|
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
|