lex-agentic-memory 0.1.9 → 0.1.11
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/trace/helpers/hot_tier.rb +98 -0
- data/lib/legion/extensions/agentic/memory/trace/helpers/postgres_store.rb +393 -0
- data/lib/legion/extensions/agentic/memory/trace.rb +22 -1
- data/lib/legion/extensions/agentic/memory/version.rb +1 -1
- data/spec/legion/extensions/agentic/memory/trace/helpers/hot_tier_spec.rb +337 -0
- data/spec/legion/extensions/agentic/memory/trace/helpers/postgres_store_spec.rb +464 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 44a4999991835bdd28a7189717f6a53f38928644a86c7f653517331687659901
|
|
4
|
+
data.tar.gz: b3aeb50fddc4b82580430829ec924549690b8dd6171be74ff9213db40b135888
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 32930b8b8092925f0793eef8ae8b1a6764d50cbc9692bb2952c46fb969137b41609eed96ecb455fdc7d0518373ebd912075eff8ac47c4deb8e97e1fc9176962b
|
|
7
|
+
data.tar.gz: a142cbf70545c4f80717ddda3d81abab26a1cc2cf5d8e40584625a416629d1551991d716fc6a9592fff194b523b0ebed2963be24fa414039cf11bdb3c934e60a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.11] - 2026-03-25
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `Helpers::HotTier` module: Redis hot-tier cache in front of PostgresStore using `Legion::Cache::RedisHash`. Stores traces as Redis hashes with 24-hour TTL, maintains a sorted-set index per tenant, and provides `cache_trace`, `fetch_trace`, and `evict_trace` operations
|
|
7
|
+
- `PostgresStore#retrieve` checks hot tier first; falls through to DB on miss and populates hot tier on DB hit
|
|
8
|
+
- `PostgresStore#store` writes through to hot tier after successful DB write
|
|
9
|
+
- `PostgresStore#delete` evicts from hot tier before DB delete
|
|
10
|
+
- `PostgresStore#update` evicts stale hot-tier entry after DB update
|
|
11
|
+
- 32 new specs covering HotTier interface, serialize/deserialize round-trip, availability guard, and all four PostgresStore integration points
|
|
12
|
+
|
|
13
|
+
## [0.1.10] - 2026-03-25
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- `Helpers::PostgresStore`: write-through durable store backed by Legion::Data (PostgreSQL or MySQL), scoped by tenant_id. Implements full store interface: store, retrieve, retrieve_by_type, retrieve_by_domain, all_traces, delete, update, record_coactivation, associations_for, walk_associations, delete_lowest_confidence, delete_least_recently_used, firmware_traces, flush (no-op), db_ready?
|
|
17
|
+
- `create_store` in `Trace` module now selects PostgresStore when a PostgreSQL or MySQL connection is available with both required tables; falls back to CacheStore or in-memory Store
|
|
18
|
+
- `postgres_available?` and `resolve_tenant_id` private helpers on `Trace` module
|
|
19
|
+
- 46 new specs covering all PostgresStore methods using an in-memory SQLite DB
|
|
20
|
+
|
|
3
21
|
## [0.1.9] - 2026-03-25
|
|
4
22
|
|
|
5
23
|
### Added
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Agentic
|
|
6
|
+
module Memory
|
|
7
|
+
module Trace
|
|
8
|
+
module Helpers
|
|
9
|
+
module HotTier
|
|
10
|
+
HOT_TTL = 86_400 # 24 hours
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Cache a trace in the Redis hot tier.
|
|
15
|
+
def cache_trace(trace, tenant_id: nil)
|
|
16
|
+
return unless available?
|
|
17
|
+
|
|
18
|
+
tid = tenant_id || trace[:partition_id]
|
|
19
|
+
key = trace_key(tid, trace[:trace_id])
|
|
20
|
+
data = serialize_trace(trace)
|
|
21
|
+
Legion::Cache::RedisHash.hset(key, data)
|
|
22
|
+
Legion::Cache::RedisHash.expire(key, HOT_TTL)
|
|
23
|
+
|
|
24
|
+
index_key = "legion:tier:hot:#{tid}"
|
|
25
|
+
Legion::Cache::RedisHash.zadd(index_key, Time.now.to_f, trace[:trace_id])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Fetch a trace from the hot tier. Returns a deserialized trace hash or nil on miss.
|
|
29
|
+
def fetch_trace(trace_id, tenant_id: nil)
|
|
30
|
+
return nil unless available?
|
|
31
|
+
|
|
32
|
+
key = trace_key(tenant_id, trace_id)
|
|
33
|
+
data = Legion::Cache::RedisHash.hgetall(key)
|
|
34
|
+
return nil if data.nil? || data.empty?
|
|
35
|
+
|
|
36
|
+
deserialize_trace(data)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Evict a trace from the hot tier and remove it from the sorted-set index.
|
|
40
|
+
def evict_trace(trace_id, tenant_id: nil)
|
|
41
|
+
return unless available?
|
|
42
|
+
|
|
43
|
+
key = trace_key(tenant_id, trace_id)
|
|
44
|
+
Legion::Cache.delete(key)
|
|
45
|
+
|
|
46
|
+
index_key = "legion:tier:hot:#{tenant_id}"
|
|
47
|
+
Legion::Cache::RedisHash.zrem(index_key, trace_id)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns true when the RedisHash module is loaded and Redis is reachable.
|
|
51
|
+
def available?
|
|
52
|
+
defined?(Legion::Cache::RedisHash) &&
|
|
53
|
+
Legion::Cache::RedisHash.redis_available?
|
|
54
|
+
rescue StandardError
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Build the namespaced Redis key for a trace.
|
|
59
|
+
def trace_key(tenant_id, trace_id)
|
|
60
|
+
"legion:trace:#{tenant_id}:#{trace_id}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Serialize a trace hash to a string-only flat hash suitable for Redis HSET.
|
|
64
|
+
def serialize_trace(trace)
|
|
65
|
+
{
|
|
66
|
+
'trace_id' => trace[:trace_id].to_s,
|
|
67
|
+
'trace_type' => trace[:trace_type].to_s,
|
|
68
|
+
'content_payload' => trace[:content_payload].to_s,
|
|
69
|
+
'strength' => trace[:strength].to_s,
|
|
70
|
+
'peak_strength' => trace[:peak_strength].to_s,
|
|
71
|
+
'confidence' => trace[:confidence].to_s,
|
|
72
|
+
'storage_tier' => 'hot',
|
|
73
|
+
'partition_id' => trace[:partition_id].to_s,
|
|
74
|
+
'last_reinforced' => (trace[:last_reinforced] || Time.now).to_s
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Deserialize a Redis string-hash back to a typed trace hash.
|
|
79
|
+
def deserialize_trace(data)
|
|
80
|
+
{
|
|
81
|
+
trace_id: data['trace_id'],
|
|
82
|
+
trace_type: data['trace_type']&.to_sym,
|
|
83
|
+
content_payload: data['content_payload'],
|
|
84
|
+
strength: data['strength']&.to_f,
|
|
85
|
+
peak_strength: data['peak_strength']&.to_f,
|
|
86
|
+
confidence: data['confidence']&.to_f,
|
|
87
|
+
storage_tier: :hot,
|
|
88
|
+
partition_id: data['partition_id'],
|
|
89
|
+
last_reinforced: data['last_reinforced']
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'hot_tier'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Agentic
|
|
8
|
+
module Memory
|
|
9
|
+
module Trace
|
|
10
|
+
module Helpers
|
|
11
|
+
# Write-through durable store backed by Legion::Data (PostgreSQL or MySQL).
|
|
12
|
+
# All writes go directly to the database — no in-memory dirty tracking, no flush.
|
|
13
|
+
# Scoped by tenant_id so multiple agents can share the same DB tables safely.
|
|
14
|
+
class PostgresStore
|
|
15
|
+
TRACES_TABLE = :memory_traces
|
|
16
|
+
ASSOCIATIONS_TABLE = :memory_associations
|
|
17
|
+
|
|
18
|
+
def initialize(tenant_id: nil)
|
|
19
|
+
@tenant_id = tenant_id
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Store (upsert) a trace by trace_id.
|
|
23
|
+
# Returns the trace_id on success, nil if the DB is not ready.
|
|
24
|
+
def store(trace)
|
|
25
|
+
return nil unless db_ready?
|
|
26
|
+
|
|
27
|
+
row = serialize_trace(trace)
|
|
28
|
+
begin
|
|
29
|
+
db[TRACES_TABLE].insert_conflict(:replace).insert(row)
|
|
30
|
+
rescue Sequel::UniqueConstraintViolation
|
|
31
|
+
db[TRACES_TABLE].where(trace_id: trace[:trace_id]).update(row.except(:trace_id))
|
|
32
|
+
end
|
|
33
|
+
HotTier.cache_trace(trace, tenant_id: @tenant_id) if HotTier.available?
|
|
34
|
+
trace[:trace_id]
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
log_warn("store failed: #{e.message}")
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Retrieve a single trace by trace_id (tenant-scoped).
|
|
41
|
+
# Checks the Redis hot tier first; falls through to DB on a miss and caches the result.
|
|
42
|
+
# Returns a trace hash or nil.
|
|
43
|
+
def retrieve(trace_id)
|
|
44
|
+
if HotTier.available?
|
|
45
|
+
cached = HotTier.fetch_trace(trace_id, tenant_id: @tenant_id)
|
|
46
|
+
return cached if cached
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
return nil unless db_ready?
|
|
50
|
+
|
|
51
|
+
row = traces_ds.where(trace_id: trace_id).first
|
|
52
|
+
trace = row ? deserialize_trace(row) : nil
|
|
53
|
+
HotTier.cache_trace(trace, tenant_id: @tenant_id) if HotTier.available? && trace
|
|
54
|
+
trace
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
log_warn("retrieve failed: #{e.message}")
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Retrieve traces by type, ordered by strength descending.
|
|
61
|
+
def retrieve_by_type(type, limit: 100, min_strength: 0.0)
|
|
62
|
+
return [] unless db_ready?
|
|
63
|
+
|
|
64
|
+
rows = traces_ds
|
|
65
|
+
.where(trace_type: type.to_s)
|
|
66
|
+
.where { strength >= min_strength }
|
|
67
|
+
.order(Sequel.desc(:strength))
|
|
68
|
+
.limit(limit)
|
|
69
|
+
.all
|
|
70
|
+
rows.map { |r| deserialize_trace(r) }
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
log_warn("retrieve_by_type failed: #{e.message}")
|
|
73
|
+
[]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Retrieve traces whose domain_tags column contains the given tag string.
|
|
77
|
+
def retrieve_by_domain(tag, limit: 50)
|
|
78
|
+
return [] unless db_ready?
|
|
79
|
+
|
|
80
|
+
rows = traces_ds
|
|
81
|
+
.where(Sequel.like(:domain_tags, "%#{tag}%"))
|
|
82
|
+
.order(Sequel.desc(:strength))
|
|
83
|
+
.limit(limit)
|
|
84
|
+
.all
|
|
85
|
+
rows.map { |r| deserialize_trace(r) }
|
|
86
|
+
rescue StandardError => e
|
|
87
|
+
log_warn("retrieve_by_domain failed: #{e.message}")
|
|
88
|
+
[]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Return all traces for this tenant.
|
|
92
|
+
def all_traces
|
|
93
|
+
return [] unless db_ready?
|
|
94
|
+
|
|
95
|
+
traces_ds.all.map { |r| deserialize_trace(r) }
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
log_warn("all_traces failed: #{e.message}")
|
|
98
|
+
[]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Delete a trace and its association rows.
|
|
102
|
+
def delete(trace_id)
|
|
103
|
+
HotTier.evict_trace(trace_id, tenant_id: @tenant_id) if HotTier.available?
|
|
104
|
+
return unless db_ready?
|
|
105
|
+
|
|
106
|
+
db[ASSOCIATIONS_TABLE].where(trace_id_a: trace_id).delete
|
|
107
|
+
db[ASSOCIATIONS_TABLE].where(trace_id_b: trace_id).delete
|
|
108
|
+
db[TRACES_TABLE].where(trace_id: trace_id).delete
|
|
109
|
+
rescue StandardError => e
|
|
110
|
+
log_warn("delete failed: #{e.message}")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Partial update of a trace by trace_id.
|
|
114
|
+
# Evicts the hot-tier entry so a stale cached version cannot be served.
|
|
115
|
+
def update(trace_id, **fields)
|
|
116
|
+
return unless db_ready?
|
|
117
|
+
|
|
118
|
+
db[TRACES_TABLE].where(trace_id: trace_id).update(map_update_fields(fields))
|
|
119
|
+
HotTier.evict_trace(trace_id, tenant_id: @tenant_id) if HotTier.available?
|
|
120
|
+
rescue StandardError => e
|
|
121
|
+
log_warn("update failed: #{e.message}")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Create or increment a coactivation association between two traces.
|
|
125
|
+
def record_coactivation(id_a, id_b)
|
|
126
|
+
return unless db_ready?
|
|
127
|
+
return if id_a == id_b
|
|
128
|
+
|
|
129
|
+
now = Time.now.utc
|
|
130
|
+
existing = db[ASSOCIATIONS_TABLE]
|
|
131
|
+
.where(trace_id_a: id_a, trace_id_b: id_b)
|
|
132
|
+
.first
|
|
133
|
+
|
|
134
|
+
if existing
|
|
135
|
+
db[ASSOCIATIONS_TABLE]
|
|
136
|
+
.where(id: existing[:id])
|
|
137
|
+
.update(
|
|
138
|
+
coactivation_count: existing[:coactivation_count] + 1,
|
|
139
|
+
updated_at: now
|
|
140
|
+
)
|
|
141
|
+
else
|
|
142
|
+
db[ASSOCIATIONS_TABLE].insert(
|
|
143
|
+
trace_id_a: id_a,
|
|
144
|
+
trace_id_b: id_b,
|
|
145
|
+
coactivation_count: 1,
|
|
146
|
+
linked: false,
|
|
147
|
+
tenant_id: @tenant_id,
|
|
148
|
+
created_at: now,
|
|
149
|
+
updated_at: now
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
rescue StandardError => e
|
|
153
|
+
log_warn("record_coactivation failed: #{e.message}")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Return the set of trace IDs associated with a given trace (bidirectional).
|
|
157
|
+
def associations_for(trace_id)
|
|
158
|
+
return [] unless db_ready?
|
|
159
|
+
|
|
160
|
+
a_side = db[ASSOCIATIONS_TABLE]
|
|
161
|
+
.where(trace_id_a: trace_id)
|
|
162
|
+
.select_map(:trace_id_b)
|
|
163
|
+
b_side = db[ASSOCIATIONS_TABLE]
|
|
164
|
+
.where(trace_id_b: trace_id)
|
|
165
|
+
.select_map(:trace_id_a)
|
|
166
|
+
(a_side + b_side).uniq
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
log_warn("associations_for failed: #{e.message}")
|
|
169
|
+
[]
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# BFS traversal starting from start_id.
|
|
173
|
+
# Returns an array of { trace_id:, depth:, path: } hashes.
|
|
174
|
+
def walk_associations(start_id:, max_hops: 12, min_strength: 0.1)
|
|
175
|
+
return [] unless db_ready?
|
|
176
|
+
|
|
177
|
+
start_row = traces_ds.where(trace_id: start_id).first
|
|
178
|
+
return [] unless start_row
|
|
179
|
+
|
|
180
|
+
results = []
|
|
181
|
+
visited = Set.new([start_id])
|
|
182
|
+
queue = [[start_id, 0, [start_id]]]
|
|
183
|
+
|
|
184
|
+
until queue.empty?
|
|
185
|
+
current_id, depth, path = queue.shift
|
|
186
|
+
neighbor_ids = associations_for(current_id)
|
|
187
|
+
|
|
188
|
+
neighbor_ids.each do |nid|
|
|
189
|
+
next if visited.include?(nid)
|
|
190
|
+
|
|
191
|
+
neighbor_row = traces_ds
|
|
192
|
+
.where(trace_id: nid)
|
|
193
|
+
.where { strength >= min_strength }
|
|
194
|
+
.first
|
|
195
|
+
next unless neighbor_row
|
|
196
|
+
|
|
197
|
+
visited << nid
|
|
198
|
+
neighbor_path = path + [nid]
|
|
199
|
+
results << { trace_id: nid, depth: depth + 1, path: neighbor_path }
|
|
200
|
+
queue << [nid, depth + 1, neighbor_path] if depth + 1 < max_hops
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
results
|
|
205
|
+
rescue StandardError => e
|
|
206
|
+
log_warn("walk_associations failed: #{e.message}")
|
|
207
|
+
[]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Delete the N traces with the lowest confidence for a given type (quota enforcement).
|
|
211
|
+
def delete_lowest_confidence(trace_type:, count:)
|
|
212
|
+
return unless db_ready?
|
|
213
|
+
|
|
214
|
+
ids = traces_ds
|
|
215
|
+
.where(trace_type: trace_type.to_s)
|
|
216
|
+
.order(:confidence)
|
|
217
|
+
.limit(count)
|
|
218
|
+
.select_map(:trace_id)
|
|
219
|
+
|
|
220
|
+
ids.each { |tid| delete(tid) }
|
|
221
|
+
rescue StandardError => e
|
|
222
|
+
log_warn("delete_lowest_confidence failed: #{e.message}")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Delete the N least-recently-used traces for a given type (quota enforcement).
|
|
226
|
+
def delete_least_recently_used(trace_type:, count:)
|
|
227
|
+
return unless db_ready?
|
|
228
|
+
|
|
229
|
+
ids = traces_ds
|
|
230
|
+
.where(trace_type: trace_type.to_s)
|
|
231
|
+
.order(:last_reinforced)
|
|
232
|
+
.limit(count)
|
|
233
|
+
.select_map(:trace_id)
|
|
234
|
+
|
|
235
|
+
ids.each { |tid| delete(tid) }
|
|
236
|
+
rescue StandardError => e
|
|
237
|
+
log_warn("delete_least_recently_used failed: #{e.message}")
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Convenience: retrieve firmware-type traces.
|
|
241
|
+
def firmware_traces
|
|
242
|
+
retrieve_by_type(:firmware)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# No-op — this store is write-through; nothing to flush.
|
|
246
|
+
def flush; end
|
|
247
|
+
|
|
248
|
+
# Returns true when both required tables exist in the connected DB.
|
|
249
|
+
def db_ready?
|
|
250
|
+
defined?(Legion::Data) &&
|
|
251
|
+
Legion::Data.respond_to?(:connection) &&
|
|
252
|
+
Legion::Data.connection&.table_exists?(TRACES_TABLE) &&
|
|
253
|
+
Legion::Data.connection.table_exists?(ASSOCIATIONS_TABLE)
|
|
254
|
+
rescue StandardError
|
|
255
|
+
false
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
private
|
|
259
|
+
|
|
260
|
+
def db
|
|
261
|
+
Legion::Data.connection
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Dataset for memory_traces scoped by tenant_id (if set).
|
|
265
|
+
def traces_ds
|
|
266
|
+
ds = db[TRACES_TABLE]
|
|
267
|
+
@tenant_id ? ds.where(tenant_id: @tenant_id) : ds
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def serialize_trace(trace)
|
|
271
|
+
payload = trace[:content_payload] || trace[:content]
|
|
272
|
+
tags = trace[:domain_tags]
|
|
273
|
+
assocs = trace[:associated_traces]
|
|
274
|
+
conf = trace[:confidence]
|
|
275
|
+
ev = trace[:emotional_valence]
|
|
276
|
+
|
|
277
|
+
{
|
|
278
|
+
trace_id: trace[:trace_id],
|
|
279
|
+
tenant_id: @tenant_id,
|
|
280
|
+
trace_type: trace[:trace_type].to_s,
|
|
281
|
+
content: payload.is_a?(Hash) ? Legion::JSON.dump(payload) : payload.to_s,
|
|
282
|
+
significance: conf,
|
|
283
|
+
confidence: conf,
|
|
284
|
+
associations: assocs.is_a?(Array) ? Legion::JSON.dump(assocs) : '[]',
|
|
285
|
+
domain_tags: tags.is_a?(Array) ? Legion::JSON.dump(tags) : nil,
|
|
286
|
+
strength: trace[:strength],
|
|
287
|
+
peak_strength: trace[:peak_strength],
|
|
288
|
+
base_decay_rate: trace[:base_decay_rate],
|
|
289
|
+
emotional_valence: ev.is_a?(Numeric) ? ev.to_f : 0.0,
|
|
290
|
+
emotional_intensity: trace[:emotional_intensity],
|
|
291
|
+
origin: trace[:origin].to_s,
|
|
292
|
+
source_agent_id: trace[:source_agent_id],
|
|
293
|
+
storage_tier: trace[:storage_tier].to_s,
|
|
294
|
+
last_reinforced: trace[:last_reinforced],
|
|
295
|
+
last_decayed: trace[:last_decayed],
|
|
296
|
+
reinforcement_count: trace[:reinforcement_count],
|
|
297
|
+
unresolved: trace[:unresolved] || false,
|
|
298
|
+
consolidation_candidate: trace[:consolidation_candidate] || false,
|
|
299
|
+
parent_trace_id: trace[:parent_trace_id],
|
|
300
|
+
encryption_key_id: trace[:encryption_key_id],
|
|
301
|
+
partition_id: trace[:partition_id],
|
|
302
|
+
created_at: trace[:created_at] || Time.now.utc,
|
|
303
|
+
accessed_at: Time.now.utc
|
|
304
|
+
}
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def deserialize_trace(row)
|
|
308
|
+
content = parse_json_or_raw(row[:content])
|
|
309
|
+
{
|
|
310
|
+
trace_id: row[:trace_id],
|
|
311
|
+
trace_type: row[:trace_type]&.to_sym,
|
|
312
|
+
content_payload: content,
|
|
313
|
+
content: content,
|
|
314
|
+
strength: row[:strength],
|
|
315
|
+
peak_strength: row[:peak_strength],
|
|
316
|
+
base_decay_rate: row[:base_decay_rate],
|
|
317
|
+
emotional_valence: row[:emotional_valence].to_f,
|
|
318
|
+
emotional_intensity: row[:emotional_intensity],
|
|
319
|
+
domain_tags: parse_json_array(row[:domain_tags]),
|
|
320
|
+
origin: row[:origin]&.to_sym,
|
|
321
|
+
source_agent_id: row[:source_agent_id],
|
|
322
|
+
created_at: row[:created_at],
|
|
323
|
+
last_reinforced: row[:last_reinforced],
|
|
324
|
+
last_decayed: row[:last_decayed],
|
|
325
|
+
reinforcement_count: row[:reinforcement_count],
|
|
326
|
+
confidence: row[:confidence],
|
|
327
|
+
storage_tier: row[:storage_tier]&.to_sym,
|
|
328
|
+
partition_id: row[:partition_id],
|
|
329
|
+
encryption_key_id: row[:encryption_key_id],
|
|
330
|
+
associated_traces: parse_json_array(row[:associations]),
|
|
331
|
+
parent_trace_id: row[:parent_trace_id],
|
|
332
|
+
child_trace_ids: [],
|
|
333
|
+
unresolved: row[:unresolved] || false,
|
|
334
|
+
consolidation_candidate: row[:consolidation_candidate] || false
|
|
335
|
+
}
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Map keyword fields for partial updates, translating to DB column names.
|
|
339
|
+
def map_update_fields(fields)
|
|
340
|
+
mapping = {
|
|
341
|
+
content_payload: :content,
|
|
342
|
+
associated_traces: :associations,
|
|
343
|
+
parent_trace_id: :parent_trace_id,
|
|
344
|
+
child_trace_ids: nil # not stored as a column
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
fields.each_with_object({}) do |(k, v), row|
|
|
348
|
+
col = mapping.key?(k) ? mapping[k] : k
|
|
349
|
+
next if col.nil?
|
|
350
|
+
|
|
351
|
+
row[col] = case col
|
|
352
|
+
when :content
|
|
353
|
+
v.is_a?(Hash) ? Legion::JSON.dump(v) : v.to_s
|
|
354
|
+
when :associations
|
|
355
|
+
v.is_a?(Array) ? Legion::JSON.dump(v) : '[]'
|
|
356
|
+
when :domain_tags
|
|
357
|
+
v.is_a?(Array) ? Legion::JSON.dump(v) : nil
|
|
358
|
+
when :trace_type, :origin, :storage_tier
|
|
359
|
+
v.to_s
|
|
360
|
+
else
|
|
361
|
+
v
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def parse_json_or_raw(raw)
|
|
367
|
+
return raw unless raw.is_a?(String)
|
|
368
|
+
|
|
369
|
+
parsed = Legion::JSON.load(raw)
|
|
370
|
+
parsed.is_a?(Hash) ? parsed : raw
|
|
371
|
+
rescue StandardError
|
|
372
|
+
raw
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def parse_json_array(raw)
|
|
376
|
+
return [] unless raw.is_a?(String)
|
|
377
|
+
|
|
378
|
+
result = Legion::JSON.load(raw)
|
|
379
|
+
result.is_a?(Array) ? result : []
|
|
380
|
+
rescue StandardError
|
|
381
|
+
[]
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def log_warn(message)
|
|
385
|
+
Legion::Logging.warn "[memory:postgres_store] #{message}" if defined?(Legion::Logging)
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
|
@@ -5,6 +5,7 @@ require 'legion/extensions/agentic/memory/trace/helpers/trace'
|
|
|
5
5
|
require 'legion/extensions/agentic/memory/trace/helpers/decay'
|
|
6
6
|
require 'legion/extensions/agentic/memory/trace/helpers/store'
|
|
7
7
|
require 'legion/extensions/agentic/memory/trace/helpers/cache_store'
|
|
8
|
+
require 'legion/extensions/agentic/memory/trace/helpers/postgres_store'
|
|
8
9
|
require 'legion/extensions/agentic/memory/trace/helpers/error_tracer'
|
|
9
10
|
require 'legion/extensions/agentic/memory/trace/runners/traces'
|
|
10
11
|
require 'legion/extensions/agentic/memory/trace/runners/consolidation'
|
|
@@ -31,7 +32,10 @@ module Legion
|
|
|
31
32
|
private
|
|
32
33
|
|
|
33
34
|
def create_store
|
|
34
|
-
if
|
|
35
|
+
if postgres_available?
|
|
36
|
+
Legion::Logging.debug '[memory] Using shared PostgresStore (write-through)'
|
|
37
|
+
Helpers::PostgresStore.new(tenant_id: resolve_tenant_id)
|
|
38
|
+
elsif defined?(Legion::Cache) && Legion::Cache.respond_to?(:connected?) && Legion::Cache.connected?
|
|
35
39
|
Legion::Logging.debug '[memory] Using shared CacheStore (memcached)'
|
|
36
40
|
Helpers::CacheStore.new
|
|
37
41
|
else
|
|
@@ -39,6 +43,23 @@ module Legion
|
|
|
39
43
|
Helpers::Store.new
|
|
40
44
|
end
|
|
41
45
|
end
|
|
46
|
+
|
|
47
|
+
def postgres_available?
|
|
48
|
+
defined?(Legion::Data) &&
|
|
49
|
+
Legion::Data.respond_to?(:connection) &&
|
|
50
|
+
Legion::Data.connection &&
|
|
51
|
+
%i[postgres mysql2].include?(Legion::Data.connection.adapter_scheme) &&
|
|
52
|
+
Legion::Data.connection.table_exists?(:memory_traces) &&
|
|
53
|
+
Legion::Data.connection.table_exists?(:memory_associations)
|
|
54
|
+
rescue StandardError
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def resolve_tenant_id
|
|
59
|
+
Legion::Settings[:data]&.dig(:tenant_id)
|
|
60
|
+
rescue StandardError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
42
63
|
end
|
|
43
64
|
end
|
|
44
65
|
end
|