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 +4 -4
- data/CHANGELOG.md +17 -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 +56 -25
- 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/local_persistence_spec.rb +48 -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: d9ec98fbb5ad987564ae50bc87572aa2d23eb3c0011dfa2c5c2a000926286eed
|
|
4
|
+
data.tar.gz: 318bae8b3abd84f2ec9e1b8101a0aed4e4f8656822356d1a9ef65557984234e9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
|
191
|
+
return nil unless trace_changes[:dirty] || associations_dirty
|
|
187
192
|
|
|
188
|
-
[traces_snapshot, associations_snapshot, trace_rows_snapshot,
|
|
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,
|
|
192
|
-
return []
|
|
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
|
-
|
|
196
|
-
ds.insert_conflict(:replace).insert(
|
|
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].
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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.
|
|
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
|
|
@@ -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.
|
|
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
|