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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f135722a81c15e36374e61e6348db1f421db9b4fc5257bc2915d8526a9b7a145
4
- data.tar.gz: 86dfb884036c9d7d8617a3a785b6b35c6cc0db413b60a9866aad04ad3e899d59
3
+ metadata.gz: 1d50530306440e9785fb5daf5e4b0a1b5ed7f4e8897758d63db2749b44bb3049
4
+ data.tar.gz: 6a92e08a370322464f1c149e6b7e3d5c0ab076d137fb6934007d7f610ec3c792
5
5
  SHA512:
6
- metadata.gz: bd080984039d516a8980ed88723a34f6f8738439f55a921e572cbba852f73af3c48010cffc9fec5b134305f2e7d0f7d774d032bf8a41e88003b9f7cef8cba34a
7
- data.tar.gz: 5e2fbe8775082a3e64533acce43937923b0e5f6c97cc7bcb623fc4a42f9bc21be513f824fef9ea80f13afb392da5e30648b68fd6e9a9fbadd1e1eeed985259b5
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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Agentic
6
+ module Memory
7
+ module Diary
8
+ VERSION = '0.1.0'
9
+ end
10
+ end
11
+ end
12
+ end
13
+ 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
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Agentic
6
6
  module Memory
7
- VERSION = '0.1.36'
7
+ VERSION = '0.1.37'
8
8
  end
9
9
  end
10
10
  end
@@ -21,6 +21,7 @@ require_relative 'memory/semantic_satiation'
21
21
  require_relative 'memory/source_monitoring'
22
22
  require_relative 'memory/transfer'
23
23
  require_relative 'memory/communication_pattern'
24
+ require_relative 'memory/diary'
24
25
 
25
26
  module Legion
26
27
  module Extensions
@@ -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.36
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