lex-agentic-memory 0.1.36 → 0.1.37
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 +8 -0
- data/lib/legion/extensions/agentic/memory/diary/client.rb +27 -0
- data/lib/legion/extensions/agentic/memory/diary/helpers/constants.rb +20 -0
- data/lib/legion/extensions/agentic/memory/diary/helpers/diary_store.rb +171 -0
- data/lib/legion/extensions/agentic/memory/diary/local_migrations/20260517000001_create_memory_diary_entries.rb +19 -0
- data/lib/legion/extensions/agentic/memory/diary/runners/diary.rb +58 -0
- data/lib/legion/extensions/agentic/memory/diary/version.rb +13 -0
- data/lib/legion/extensions/agentic/memory/diary.rb +41 -0
- data/lib/legion/extensions/agentic/memory/version.rb +1 -1
- data/lib/legion/extensions/agentic/memory.rb +1 -0
- data/spec/legion/extensions/agentic/memory/diary/client_spec.rb +45 -0
- data/spec/legion/extensions/agentic/memory/diary/helpers/constants_spec.rb +21 -0
- data/spec/legion/extensions/agentic/memory/diary/helpers/diary_store_spec.rb +224 -0
- data/spec/legion/extensions/agentic/memory/diary/runners/diary_spec.rb +134 -0
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1d50530306440e9785fb5daf5e4b0a1b5ed7f4e8897758d63db2749b44bb3049
|
|
4
|
+
data.tar.gz: 6a92e08a370322464f1c149e6b7e3d5c0ab076d137fb6934007d7f610ec3c792
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7b05da20183b1d7799fd2eafe2d7fe01cec596ccd4be2835197ba7ef44e55bf5c0ba7592616233949ad58de12a0c24cf555b9c68ec52daf97c25dddc79327202
|
|
7
|
+
data.tar.gz: ed22f62934d8a013e870466839d7573714a064c0d56b42fbf7d00a412cf107cf69e2f33b3c5bbd18bf8c7ddb064e8bab0db9957f03dff80cc6419d30658515e0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.37] - 2026-05-17
|
|
4
|
+
### Added
|
|
5
|
+
- `Memory::Diary` sub-module — per-agent chronological session diary with write, read, and search.
|
|
6
|
+
- `DiaryStore` backed by `Legion::Data::Local` (SQLite) with agent-scoped isolation.
|
|
7
|
+
- Local migration `20260517000001_create_memory_diary_entries` for diary table.
|
|
8
|
+
- `Diary::Client` and `Runners::Diary` for runner integration (write_diary, read_diary, search_diary, diary_stats).
|
|
9
|
+
- Module-level convenience API: `Memory::Diary.write`, `.read`, `.search`.
|
|
10
|
+
|
|
3
11
|
## [0.1.36] - 2026-05-17
|
|
4
12
|
### Fixed
|
|
5
13
|
- `PostgresStore#db_ready?` now checks `Legion::Data.can_write?(:memory_traces)` before attempting writes, preventing `PG::InsufficientPrivilege` errors when connected with a read-only role.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/agentic/memory/diary/helpers/constants'
|
|
4
|
+
require 'legion/extensions/agentic/memory/diary/helpers/diary_store'
|
|
5
|
+
require 'legion/extensions/agentic/memory/diary/runners/diary'
|
|
6
|
+
|
|
7
|
+
module Legion
|
|
8
|
+
module Extensions
|
|
9
|
+
module Agentic
|
|
10
|
+
module Memory
|
|
11
|
+
module Diary
|
|
12
|
+
class Client
|
|
13
|
+
include Runners::Diary
|
|
14
|
+
|
|
15
|
+
def initialize(agent_id: nil, store: nil)
|
|
16
|
+
@diary_store = store || Helpers::DiaryStore.new(agent_id: agent_id)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
attr_reader :diary_store
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Agentic
|
|
6
|
+
module Memory
|
|
7
|
+
module Diary
|
|
8
|
+
module Helpers
|
|
9
|
+
module Constants
|
|
10
|
+
TABLE_NAME = :memory_diary_entries
|
|
11
|
+
DEFAULT_LIMIT = 20
|
|
12
|
+
MAX_LIMIT = 200
|
|
13
|
+
MAX_CONTENT_SIZE = 65_536
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Agentic
|
|
8
|
+
module Memory
|
|
9
|
+
module Diary
|
|
10
|
+
module Helpers
|
|
11
|
+
class DiaryStore
|
|
12
|
+
include Legion::Logging::Helper if defined?(Legion::Logging::Helper)
|
|
13
|
+
|
|
14
|
+
def initialize(agent_id: nil)
|
|
15
|
+
@agent_id = agent_id || resolve_agent_id
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader :agent_id
|
|
19
|
+
|
|
20
|
+
def write(session_id:, content:, tags: [], metadata: {})
|
|
21
|
+
return nil unless db_ready?
|
|
22
|
+
|
|
23
|
+
entry_id = SecureRandom.uuid
|
|
24
|
+
now = Time.now.utc
|
|
25
|
+
row = {
|
|
26
|
+
entry_id: entry_id,
|
|
27
|
+
agent_id: @agent_id,
|
|
28
|
+
session_id: session_id,
|
|
29
|
+
content: sanitize(content.to_s[0...Constants::MAX_CONTENT_SIZE]),
|
|
30
|
+
tags: tags.is_a?(Array) ? Legion::JSON.dump(tags) : '[]',
|
|
31
|
+
metadata: metadata.is_a?(Hash) ? Legion::JSON.dump(metadata) : '{}',
|
|
32
|
+
created_at: now
|
|
33
|
+
}
|
|
34
|
+
db[Constants::TABLE_NAME].insert(row)
|
|
35
|
+
entry_id
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
log_warn("write failed: #{e.message}")
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def read(limit: Constants::DEFAULT_LIMIT, since: nil)
|
|
42
|
+
return [] unless db_ready?
|
|
43
|
+
|
|
44
|
+
effective_limit = [limit, Constants::MAX_LIMIT].min
|
|
45
|
+
ds = scoped_ds.order(Sequel.asc(:created_at)).limit(effective_limit)
|
|
46
|
+
ds = ds.where { created_at >= since } if since
|
|
47
|
+
ds.all.map { |row| deserialize(row) }
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
log_warn("read failed: #{e.message}")
|
|
50
|
+
[]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def search(query:, limit: Constants::DEFAULT_LIMIT)
|
|
54
|
+
return [] unless db_ready?
|
|
55
|
+
return [] if query.nil? || query.strip.empty?
|
|
56
|
+
|
|
57
|
+
effective_limit = [limit, Constants::MAX_LIMIT].min
|
|
58
|
+
ds = scoped_ds
|
|
59
|
+
.where(Sequel.like(:content, "%#{sanitize(query)}%"))
|
|
60
|
+
.order(Sequel.desc(:created_at))
|
|
61
|
+
.limit(effective_limit)
|
|
62
|
+
ds.all.map { |row| deserialize(row) }
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
log_warn("search failed: #{e.message}")
|
|
65
|
+
[]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def get(entry_id)
|
|
69
|
+
return nil unless db_ready?
|
|
70
|
+
|
|
71
|
+
row = scoped_ds.where(entry_id: entry_id).first
|
|
72
|
+
row ? deserialize(row) : nil
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
log_warn("get failed: #{e.message}")
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def delete(entry_id)
|
|
79
|
+
return false unless db_ready?
|
|
80
|
+
|
|
81
|
+
scoped_ds.where(entry_id: entry_id).delete
|
|
82
|
+
true
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
log_warn("delete failed: #{e.message}")
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def count
|
|
89
|
+
return 0 unless db_ready?
|
|
90
|
+
|
|
91
|
+
scoped_ds.count
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
log_warn("count failed: #{e.message}")
|
|
94
|
+
0
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def db_ready?
|
|
98
|
+
defined?(Legion::Data::Local) &&
|
|
99
|
+
Legion::Data::Local.respond_to?(:connected?) &&
|
|
100
|
+
Legion::Data::Local.connected? &&
|
|
101
|
+
Legion::Data::Local.connection&.table_exists?(Constants::TABLE_NAME)
|
|
102
|
+
rescue StandardError => e
|
|
103
|
+
log_warn("db_ready?: #{e.message}")
|
|
104
|
+
false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def db
|
|
110
|
+
Legion::Data::Local.connection
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def scoped_ds
|
|
114
|
+
db[Constants::TABLE_NAME].where(agent_id: @agent_id)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def resolve_agent_id
|
|
118
|
+
Legion::Settings.dig(:agent, :id) || 'default'
|
|
119
|
+
rescue StandardError => e
|
|
120
|
+
log_warn("resolve_agent_id: #{e.message}")
|
|
121
|
+
'default'
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def deserialize(row)
|
|
125
|
+
{
|
|
126
|
+
entry_id: row[:entry_id],
|
|
127
|
+
agent_id: row[:agent_id],
|
|
128
|
+
session_id: row[:session_id],
|
|
129
|
+
content: row[:content],
|
|
130
|
+
tags: parse_json_array(row[:tags]),
|
|
131
|
+
metadata: parse_json_hash(row[:metadata]),
|
|
132
|
+
created_at: row[:created_at]
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def parse_json_array(raw)
|
|
137
|
+
return [] if raw.nil? || raw.to_s.strip.empty?
|
|
138
|
+
|
|
139
|
+
result = Legion::JSON.load(raw)
|
|
140
|
+
result.is_a?(Array) ? result : []
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
log_warn("parse_json_array: #{e.message}")
|
|
143
|
+
[]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def parse_json_hash(raw)
|
|
147
|
+
return {} if raw.nil? || raw.to_s.strip.empty?
|
|
148
|
+
|
|
149
|
+
result = Legion::JSON.load(raw)
|
|
150
|
+
result.is_a?(Hash) ? result : {}
|
|
151
|
+
rescue StandardError => e
|
|
152
|
+
log_warn("parse_json_hash: #{e.message}")
|
|
153
|
+
{}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def sanitize(value)
|
|
157
|
+
return value unless value.is_a?(String)
|
|
158
|
+
|
|
159
|
+
value.delete("\x00")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def log_warn(message)
|
|
163
|
+
log.warn "[diary] #{message}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Sequel.migration do
|
|
4
|
+
change do
|
|
5
|
+
create_table(:memory_diary_entries) do
|
|
6
|
+
primary_key :id
|
|
7
|
+
String :entry_id, size: 36, null: false, unique: true
|
|
8
|
+
String :agent_id, size: 64, null: false
|
|
9
|
+
String :session_id, size: 64
|
|
10
|
+
String :content, text: true, null: false
|
|
11
|
+
String :tags, text: true
|
|
12
|
+
String :metadata, text: true
|
|
13
|
+
DateTime :created_at, null: false
|
|
14
|
+
|
|
15
|
+
index :agent_id
|
|
16
|
+
index %i[agent_id created_at]
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Agentic
|
|
6
|
+
module Memory
|
|
7
|
+
module Diary
|
|
8
|
+
module Runners
|
|
9
|
+
module Diary
|
|
10
|
+
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
|
|
11
|
+
Legion::Extensions::Helpers.const_defined?(:Lex, false)
|
|
12
|
+
|
|
13
|
+
def write_diary(session_id:, content:, tags: [], metadata: {}, store: nil, **)
|
|
14
|
+
s = store || diary_store
|
|
15
|
+
entry_id = s.write(session_id: session_id, content: content, tags: tags, metadata: metadata)
|
|
16
|
+
if entry_id
|
|
17
|
+
log.debug("[diary] wrote entry=#{entry_id} agent=#{s.agent_id} session=#{session_id}")
|
|
18
|
+
{ success: true, entry_id: entry_id }
|
|
19
|
+
else
|
|
20
|
+
{ success: false, error: 'diary store not available' }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def read_diary(limit: Helpers::Constants::DEFAULT_LIMIT, since: nil, store: nil, **)
|
|
25
|
+
s = store || diary_store
|
|
26
|
+
return { success: false, error: 'diary store not available' } unless s.db_ready?
|
|
27
|
+
|
|
28
|
+
entries = s.read(limit: limit, since: since)
|
|
29
|
+
log.debug("[diary] read #{entries.size} entries for agent=#{s.agent_id}")
|
|
30
|
+
{ success: true, entries: entries, count: entries.size }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def search_diary(query:, limit: Helpers::Constants::DEFAULT_LIMIT, store: nil, **)
|
|
34
|
+
s = store || diary_store
|
|
35
|
+
return { success: false, error: 'diary store not available' } unless s.db_ready?
|
|
36
|
+
|
|
37
|
+
entries = s.search(query: query, limit: limit)
|
|
38
|
+
log.debug("[diary] search query=#{query} found=#{entries.size} agent=#{s.agent_id}")
|
|
39
|
+
{ success: true, entries: entries, count: entries.size }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def diary_stats(store: nil, **)
|
|
43
|
+
s = store || diary_store
|
|
44
|
+
{ success: true, agent_id: s.agent_id, entry_count: s.count, available: s.db_ready? }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def diary_store
|
|
50
|
+
@diary_store ||= Helpers::DiaryStore.new
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/agentic/memory/diary/version'
|
|
4
|
+
require 'legion/extensions/agentic/memory/diary/helpers/constants'
|
|
5
|
+
require 'legion/extensions/agentic/memory/diary/helpers/diary_store'
|
|
6
|
+
require 'legion/extensions/agentic/memory/diary/runners/diary'
|
|
7
|
+
require 'legion/extensions/agentic/memory/diary/client'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module Extensions
|
|
11
|
+
module Agentic
|
|
12
|
+
module Memory
|
|
13
|
+
module Diary
|
|
14
|
+
class << self
|
|
15
|
+
def write(session_id:, content:, agent_id: nil, tags: [], metadata: {})
|
|
16
|
+
store = Helpers::DiaryStore.new(agent_id: agent_id)
|
|
17
|
+
store.write(session_id: session_id, content: content, tags: tags, metadata: metadata)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def read(agent_id: nil, limit: Helpers::Constants::DEFAULT_LIMIT, since: nil)
|
|
21
|
+
store = Helpers::DiaryStore.new(agent_id: agent_id)
|
|
22
|
+
store.read(limit: limit, since: since)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def search(query:, agent_id: nil, limit: Helpers::Constants::DEFAULT_LIMIT)
|
|
26
|
+
store = Helpers::DiaryStore.new(agent_id: agent_id)
|
|
27
|
+
store.search(query: query, limit: limit)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
if defined?(Legion::Data::Local)
|
|
36
|
+
Legion::Data::Local.register_migrations(
|
|
37
|
+
name: :diary,
|
|
38
|
+
path: File.join(__dir__, 'diary', 'local_migrations')
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Agentic::Memory::Diary::Client do
|
|
6
|
+
let(:store) { instance_double(Legion::Extensions::Agentic::Memory::Diary::Helpers::DiaryStore) }
|
|
7
|
+
let(:client) { described_class.new(store: store) }
|
|
8
|
+
|
|
9
|
+
before do
|
|
10
|
+
allow(store).to receive(:agent_id).and_return('test-agent')
|
|
11
|
+
allow(store).to receive(:db_ready?).and_return(true)
|
|
12
|
+
allow(store).to receive(:count).and_return(5)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'includes Runners::Diary' do
|
|
16
|
+
expect(described_class.ancestors).to include(Legion::Extensions::Agentic::Memory::Diary::Runners::Diary)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'delegates write_diary to the store' do
|
|
20
|
+
allow(store).to receive(:write).and_return('entry-uuid')
|
|
21
|
+
result = client.write_diary(session_id: 'sess-1', content: 'notes')
|
|
22
|
+
expect(result[:success]).to be true
|
|
23
|
+
expect(result[:entry_id]).to eq('entry-uuid')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'delegates read_diary to the store' do
|
|
27
|
+
allow(store).to receive(:read).and_return([{ entry_id: 'e1', content: 'test' }])
|
|
28
|
+
result = client.read_diary
|
|
29
|
+
expect(result[:success]).to be true
|
|
30
|
+
expect(result[:count]).to eq(1)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'delegates search_diary to the store' do
|
|
34
|
+
allow(store).to receive(:search).and_return([])
|
|
35
|
+
result = client.search_diary(query: 'redis')
|
|
36
|
+
expect(result[:success]).to be true
|
|
37
|
+
expect(result[:count]).to eq(0)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'returns diary_stats' do
|
|
41
|
+
result = client.diary_stats
|
|
42
|
+
expect(result[:agent_id]).to eq('test-agent')
|
|
43
|
+
expect(result[:entry_count]).to eq(5)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Agentic::Memory::Diary::Helpers::Constants do
|
|
6
|
+
it 'defines TABLE_NAME' do
|
|
7
|
+
expect(described_class::TABLE_NAME).to eq(:memory_diary_entries)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
it 'defines DEFAULT_LIMIT' do
|
|
11
|
+
expect(described_class::DEFAULT_LIMIT).to eq(20)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'defines MAX_LIMIT' do
|
|
15
|
+
expect(described_class::MAX_LIMIT).to eq(200)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'defines MAX_CONTENT_SIZE' do
|
|
19
|
+
expect(described_class::MAX_CONTENT_SIZE).to eq(65_536)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'sequel'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
unless defined?(Legion::Data::Local)
|
|
8
|
+
module Legion
|
|
9
|
+
module Data
|
|
10
|
+
module Local
|
|
11
|
+
class << self
|
|
12
|
+
attr_reader :connection
|
|
13
|
+
|
|
14
|
+
def connected?
|
|
15
|
+
!@connection.nil?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def respond_to?(method, *)
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
RSpec.describe Legion::Extensions::Agentic::Memory::Diary::Helpers::DiaryStore do
|
|
28
|
+
let(:db) do
|
|
29
|
+
d = Sequel.sqlite
|
|
30
|
+
d.create_table(:memory_diary_entries) do
|
|
31
|
+
primary_key :id
|
|
32
|
+
String :entry_id, size: 36, null: false, unique: true
|
|
33
|
+
String :agent_id, size: 64, null: false
|
|
34
|
+
String :session_id, size: 64
|
|
35
|
+
String :content, text: true, null: false
|
|
36
|
+
String :tags, text: true
|
|
37
|
+
String :metadata, text: true
|
|
38
|
+
DateTime :created_at, null: false
|
|
39
|
+
|
|
40
|
+
index :agent_id
|
|
41
|
+
index %i[agent_id created_at]
|
|
42
|
+
end
|
|
43
|
+
d
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
let(:store) { described_class.new(agent_id: 'test-agent') }
|
|
47
|
+
|
|
48
|
+
before do
|
|
49
|
+
allow(Legion::Data::Local).to receive(:respond_to?).and_call_original
|
|
50
|
+
allow(Legion::Data::Local).to receive(:respond_to?).with(:connected?).and_return(true)
|
|
51
|
+
allow(Legion::Data::Local).to receive(:connected?).and_return(true)
|
|
52
|
+
allow(Legion::Data::Local).to receive(:connection).and_return(db)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
describe '#db_ready?' do
|
|
56
|
+
it 'returns true when local DB is connected and table exists' do
|
|
57
|
+
expect(store.db_ready?).to be true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'returns false when table does not exist' do
|
|
61
|
+
db.drop_table(:memory_diary_entries)
|
|
62
|
+
expect(store.db_ready?).to be false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'returns false when Local is not connected' do
|
|
66
|
+
allow(Legion::Data::Local).to receive(:connected?).and_return(false)
|
|
67
|
+
expect(store.db_ready?).to be false
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
describe '#write' do
|
|
72
|
+
it 'creates a diary entry and returns the entry_id' do
|
|
73
|
+
entry_id = store.write(session_id: 'sess-1', content: 'learned about caching')
|
|
74
|
+
expect(entry_id).to be_a(String)
|
|
75
|
+
expect(entry_id.length).to eq(36)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'persists the entry to the database' do
|
|
79
|
+
store.write(session_id: 'sess-1', content: 'session notes', tags: %w[decisions])
|
|
80
|
+
row = db[:memory_diary_entries].first
|
|
81
|
+
expect(row[:agent_id]).to eq('test-agent')
|
|
82
|
+
expect(row[:session_id]).to eq('sess-1')
|
|
83
|
+
expect(row[:content]).to eq('session notes')
|
|
84
|
+
expect(JSON.parse(row[:tags])).to eq(['decisions'])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'returns nil when db is not ready' do
|
|
88
|
+
allow(Legion::Data::Local).to receive(:connected?).and_return(false)
|
|
89
|
+
expect(store.write(session_id: 'sess-1', content: 'test')).to be_nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'strips null bytes from content' do
|
|
93
|
+
store.write(session_id: 'sess-1', content: "hello\x00world")
|
|
94
|
+
row = db[:memory_diary_entries].first
|
|
95
|
+
expect(row[:content]).to eq('helloworld')
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'truncates content beyond MAX_CONTENT_SIZE' do
|
|
99
|
+
long_content = 'x' * 100_000
|
|
100
|
+
store.write(session_id: 'sess-1', content: long_content)
|
|
101
|
+
row = db[:memory_diary_entries].first
|
|
102
|
+
expect(row[:content].length).to eq(65_536)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
describe '#read' do
|
|
107
|
+
before do
|
|
108
|
+
store.write(session_id: 'sess-1', content: 'first entry', tags: %w[boot])
|
|
109
|
+
store.write(session_id: 'sess-2', content: 'second entry', tags: %w[work])
|
|
110
|
+
store.write(session_id: 'sess-3', content: 'third entry', tags: %w[debug])
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'returns entries in chronological order (oldest first)' do
|
|
114
|
+
entries = store.read
|
|
115
|
+
expect(entries.size).to eq(3)
|
|
116
|
+
expect(entries.first[:content]).to eq('first entry')
|
|
117
|
+
expect(entries.last[:content]).to eq('third entry')
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it 'respects the limit parameter' do
|
|
121
|
+
entries = store.read(limit: 2)
|
|
122
|
+
expect(entries.size).to eq(2)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'caps limit at MAX_LIMIT' do
|
|
126
|
+
entries = store.read(limit: 500)
|
|
127
|
+
expect(entries.size).to eq(3)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it 'scopes to the current agent only' do
|
|
131
|
+
other = described_class.new(agent_id: 'other-agent')
|
|
132
|
+
other.write(session_id: 'sess-x', content: 'other agent entry')
|
|
133
|
+
entries = store.read
|
|
134
|
+
expect(entries.size).to eq(3)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it 'deserializes tags as an array' do
|
|
138
|
+
entries = store.read(limit: 1)
|
|
139
|
+
expect(entries.first[:tags]).to eq(['boot'])
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it 'returns empty array when db not ready' do
|
|
143
|
+
allow(Legion::Data::Local).to receive(:connected?).and_return(false)
|
|
144
|
+
expect(store.read).to eq([])
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
describe '#search' do
|
|
149
|
+
before do
|
|
150
|
+
store.write(session_id: 'sess-1', content: 'decided to use Redis for caching')
|
|
151
|
+
store.write(session_id: 'sess-2', content: 'discussed database migration plan')
|
|
152
|
+
store.write(session_id: 'sess-3', content: 'optimized Redis pool settings')
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it 'returns entries matching the query' do
|
|
156
|
+
entries = store.search(query: 'Redis')
|
|
157
|
+
expect(entries.size).to eq(2)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it 'returns empty for no match' do
|
|
161
|
+
entries = store.search(query: 'nonexistent')
|
|
162
|
+
expect(entries.empty?).to be true
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'returns empty for nil/blank query' do
|
|
166
|
+
expect(store.search(query: nil)).to eq([])
|
|
167
|
+
expect(store.search(query: ' ')).to eq([])
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
it 'scopes to current agent' do
|
|
171
|
+
other = described_class.new(agent_id: 'other-agent')
|
|
172
|
+
other.write(session_id: 'sess-x', content: 'Redis in other agent')
|
|
173
|
+
entries = store.search(query: 'Redis')
|
|
174
|
+
expect(entries.size).to eq(2)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
describe '#get' do
|
|
179
|
+
it 'retrieves a single entry by entry_id' do
|
|
180
|
+
entry_id = store.write(session_id: 'sess-1', content: 'test entry')
|
|
181
|
+
entry = store.get(entry_id)
|
|
182
|
+
expect(entry[:entry_id]).to eq(entry_id)
|
|
183
|
+
expect(entry[:content]).to eq('test entry')
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it 'returns nil for unknown entry_id' do
|
|
187
|
+
expect(store.get('nonexistent')).to be_nil
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it 'does not return entries from another agent' do
|
|
191
|
+
entry_id = store.write(session_id: 'sess-1', content: 'mine')
|
|
192
|
+
other = described_class.new(agent_id: 'other-agent')
|
|
193
|
+
expect(other.get(entry_id)).to be_nil
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
describe '#delete' do
|
|
198
|
+
it 'removes the entry' do
|
|
199
|
+
entry_id = store.write(session_id: 'sess-1', content: 'deletable')
|
|
200
|
+
expect(store.delete(entry_id)).to be true
|
|
201
|
+
expect(store.get(entry_id)).to be_nil
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
it 'returns false when db not ready' do
|
|
205
|
+
allow(Legion::Data::Local).to receive(:connected?).and_return(false)
|
|
206
|
+
expect(store.delete('any')).to be false
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
describe '#count' do
|
|
211
|
+
it 'returns the number of entries for the agent' do
|
|
212
|
+
store.write(session_id: 'sess-1', content: 'one')
|
|
213
|
+
store.write(session_id: 'sess-2', content: 'two')
|
|
214
|
+
expect(store.count).to eq(2)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
it 'does not count entries from other agents' do
|
|
218
|
+
store.write(session_id: 'sess-1', content: 'mine')
|
|
219
|
+
other = described_class.new(agent_id: 'other-agent')
|
|
220
|
+
other.write(session_id: 'sess-x', content: 'theirs')
|
|
221
|
+
expect(store.count).to eq(1)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'sequel'
|
|
5
|
+
|
|
6
|
+
unless defined?(Legion::Data::Local)
|
|
7
|
+
module Legion
|
|
8
|
+
module Data
|
|
9
|
+
module Local
|
|
10
|
+
class << self
|
|
11
|
+
attr_reader :connection
|
|
12
|
+
|
|
13
|
+
def connected?
|
|
14
|
+
!@connection.nil?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def respond_to?(method, *)
|
|
18
|
+
super
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
RSpec.describe Legion::Extensions::Agentic::Memory::Diary::Runners::Diary do
|
|
27
|
+
let(:db) do
|
|
28
|
+
d = Sequel.sqlite
|
|
29
|
+
d.create_table(:memory_diary_entries) do
|
|
30
|
+
primary_key :id
|
|
31
|
+
String :entry_id, size: 36, null: false, unique: true
|
|
32
|
+
String :agent_id, size: 64, null: false
|
|
33
|
+
String :session_id, size: 64
|
|
34
|
+
String :content, text: true, null: false
|
|
35
|
+
String :tags, text: true
|
|
36
|
+
String :metadata, text: true
|
|
37
|
+
DateTime :created_at, null: false
|
|
38
|
+
|
|
39
|
+
index :agent_id
|
|
40
|
+
index %i[agent_id created_at]
|
|
41
|
+
end
|
|
42
|
+
d
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
let(:store) { Legion::Extensions::Agentic::Memory::Diary::Helpers::DiaryStore.new(agent_id: 'runner-agent') }
|
|
46
|
+
|
|
47
|
+
let(:runner) do
|
|
48
|
+
client = Legion::Extensions::Agentic::Memory::Diary::Client.new(store: store)
|
|
49
|
+
client
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
before do
|
|
53
|
+
allow(Legion::Data::Local).to receive(:respond_to?).and_call_original
|
|
54
|
+
allow(Legion::Data::Local).to receive(:respond_to?).with(:connected?).and_return(true)
|
|
55
|
+
allow(Legion::Data::Local).to receive(:connected?).and_return(true)
|
|
56
|
+
allow(Legion::Data::Local).to receive(:connection).and_return(db)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
describe '#write_diary' do
|
|
60
|
+
it 'writes an entry and returns success with entry_id' do
|
|
61
|
+
result = runner.write_diary(session_id: 'sess-1', content: 'test notes')
|
|
62
|
+
expect(result[:success]).to be true
|
|
63
|
+
expect(result[:entry_id]).to be_a(String)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'returns failure when store is not available' do
|
|
67
|
+
allow(Legion::Data::Local).to receive(:connected?).and_return(false)
|
|
68
|
+
result = runner.write_diary(session_id: 'sess-1', content: 'test')
|
|
69
|
+
expect(result[:success]).to be false
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
describe '#read_diary' do
|
|
74
|
+
before do
|
|
75
|
+
runner.write_diary(session_id: 'sess-1', content: 'entry one')
|
|
76
|
+
runner.write_diary(session_id: 'sess-2', content: 'entry two')
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it 'returns entries with count' do
|
|
80
|
+
result = runner.read_diary
|
|
81
|
+
expect(result[:success]).to be true
|
|
82
|
+
expect(result[:count]).to eq(2)
|
|
83
|
+
expect(result[:entries].size).to eq(2)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it 'respects limit' do
|
|
87
|
+
result = runner.read_diary(limit: 1)
|
|
88
|
+
expect(result[:count]).to eq(1)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it 'returns failure when store is not available' do
|
|
92
|
+
allow(Legion::Data::Local).to receive(:connected?).and_return(false)
|
|
93
|
+
result = runner.read_diary
|
|
94
|
+
expect(result[:success]).to be false
|
|
95
|
+
expect(result[:error]).to eq('diary store not available')
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
describe '#search_diary' do
|
|
100
|
+
before do
|
|
101
|
+
runner.write_diary(session_id: 'sess-1', content: 'discussed caching strategy')
|
|
102
|
+
runner.write_diary(session_id: 'sess-2', content: 'fixed database bug')
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it 'finds entries matching query' do
|
|
106
|
+
result = runner.search_diary(query: 'caching')
|
|
107
|
+
expect(result[:success]).to be true
|
|
108
|
+
expect(result[:count]).to eq(1)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'returns empty for no match' do
|
|
112
|
+
result = runner.search_diary(query: 'nonexistent')
|
|
113
|
+
expect(result[:count]).to eq(0)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it 'returns failure when store is not available' do
|
|
117
|
+
allow(Legion::Data::Local).to receive(:connected?).and_return(false)
|
|
118
|
+
result = runner.search_diary(query: 'caching')
|
|
119
|
+
expect(result[:success]).to be false
|
|
120
|
+
expect(result[:error]).to eq('diary store not available')
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
describe '#diary_stats' do
|
|
125
|
+
it 'returns stats for the agent diary' do
|
|
126
|
+
runner.write_diary(session_id: 'sess-1', content: 'test')
|
|
127
|
+
result = runner.diary_stats
|
|
128
|
+
expect(result[:success]).to be true
|
|
129
|
+
expect(result[:agent_id]).to eq('runner-agent')
|
|
130
|
+
expect(result[:entry_count]).to eq(1)
|
|
131
|
+
expect(result[:available]).to be true
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
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.37
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -216,6 +216,13 @@ files:
|
|
|
216
216
|
- lib/legion/extensions/agentic/memory/consolidation.rb
|
|
217
217
|
- lib/legion/extensions/agentic/memory/consolidation/helpers/extractor.rb
|
|
218
218
|
- lib/legion/extensions/agentic/memory/consolidation/pre_compact.rb
|
|
219
|
+
- lib/legion/extensions/agentic/memory/diary.rb
|
|
220
|
+
- lib/legion/extensions/agentic/memory/diary/client.rb
|
|
221
|
+
- lib/legion/extensions/agentic/memory/diary/helpers/constants.rb
|
|
222
|
+
- lib/legion/extensions/agentic/memory/diary/helpers/diary_store.rb
|
|
223
|
+
- lib/legion/extensions/agentic/memory/diary/local_migrations/20260517000001_create_memory_diary_entries.rb
|
|
224
|
+
- lib/legion/extensions/agentic/memory/diary/runners/diary.rb
|
|
225
|
+
- lib/legion/extensions/agentic/memory/diary/version.rb
|
|
219
226
|
- lib/legion/extensions/agentic/memory/echo.rb
|
|
220
227
|
- lib/legion/extensions/agentic/memory/echo/actors/decay.rb
|
|
221
228
|
- lib/legion/extensions/agentic/memory/echo/client.rb
|
|
@@ -383,6 +390,10 @@ files:
|
|
|
383
390
|
- spec/legion/extensions/agentic/memory/compression/runners/cognitive_compression_spec.rb
|
|
384
391
|
- spec/legion/extensions/agentic/memory/consolidation/helpers/extractor_spec.rb
|
|
385
392
|
- spec/legion/extensions/agentic/memory/consolidation/pre_compact_spec.rb
|
|
393
|
+
- spec/legion/extensions/agentic/memory/diary/client_spec.rb
|
|
394
|
+
- spec/legion/extensions/agentic/memory/diary/helpers/constants_spec.rb
|
|
395
|
+
- spec/legion/extensions/agentic/memory/diary/helpers/diary_store_spec.rb
|
|
396
|
+
- spec/legion/extensions/agentic/memory/diary/runners/diary_spec.rb
|
|
386
397
|
- spec/legion/extensions/agentic/memory/echo/actors/decay_spec.rb
|
|
387
398
|
- spec/legion/extensions/agentic/memory/echo/client_spec.rb
|
|
388
399
|
- spec/legion/extensions/agentic/memory/echo/cognitive_echo_spec.rb
|