Skip to content

Commit da440e1

Browse files
feat(memory): Implement SQLite Memory Store with FTS5 (Task 2.9)
- Add SqliteMemoryStore implementation with IMemoryStore interface - Implement CRUD operations (Store, Get, Delete) - Add FTS5 full-text search with BM25 ranking - Add ListAsync with category filtering and limit support - Add CountAsync - Add VectorSearchAsync stub (returns empty for now) - Create test project with 16 unit tests - All tests pass using TDD methodology Implements: Task 2.9 from phase-2-backend.md
1 parent 6837cca commit da440e1

File tree

5 files changed

+409
-0
lines changed

5 files changed

+409
-0
lines changed

ClawSharp.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
<Project Path="tests/ClawSharp.Cli.Tests/ClawSharp.Cli.Tests.csproj" />
1717
<Project Path="tests/ClawSharp.Core.Tests/ClawSharp.Core.Tests.csproj" />
1818
<Project Path="tests/ClawSharp.Infrastructure.Tests/ClawSharp.Infrastructure.Tests.csproj" />
19+
<Project Path="tests/ClawSharp.Memory.Tests/ClawSharp.Memory.Tests.csproj" />
1920
<Project Path="tests/ClawSharp.Providers.Tests/ClawSharp.Providers.Tests.csproj" />
2021
<Project Path="tests/ClawSharp.Tools.Tests/ClawSharp.Tools.Tests.csproj" />
2122
<Project Path="tests/ClawSharp.TestHelpers/ClawSharp.TestHelpers.csproj" />

src/ClawSharp.Memory/ClawSharp.Memory.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

3+
<ItemGroup>
4+
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.11" />
5+
</ItemGroup>
6+
37
<ItemGroup>
48
<ProjectReference Include="..\ClawSharp.Core\ClawSharp.Core.csproj" />
59
</ItemGroup>
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
using Microsoft.Data.Sqlite;
2+
using ClawSharp.Core.Memory;
3+
4+
namespace ClawSharp.Memory;
5+
6+
/// <summary>
7+
/// SQLite-backed memory store with FTS5 full-text search.
8+
/// </summary>
9+
public class SqliteMemoryStore : IMemoryStore, IAsyncDisposable
10+
{
11+
private readonly SqliteConnection _connection;
12+
private bool _disposed;
13+
14+
public SqliteMemoryStore(string connectionString)
15+
{
16+
// Handle both full connection strings and ":memory:" shorthand
17+
var actualConnectionString = connectionString == ":memory:"
18+
? "Data Source=:memory:"
19+
: connectionString;
20+
21+
_connection = new SqliteConnection(actualConnectionString);
22+
_connection.Open();
23+
InitializeSchema();
24+
}
25+
26+
private void InitializeSchema()
27+
{
28+
using var cmd = _connection.CreateCommand();
29+
cmd.CommandText = """
30+
CREATE TABLE IF NOT EXISTS memories (
31+
id TEXT PRIMARY KEY,
32+
key TEXT NOT NULL UNIQUE,
33+
content TEXT NOT NULL,
34+
category INTEGER NOT NULL,
35+
session_id TEXT,
36+
timestamp TEXT NOT NULL
37+
);
38+
CREATE INDEX IF NOT EXISTS idx_memories_key ON memories(key);
39+
CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(category);
40+
CREATE INDEX IF NOT EXISTS idx_memories_timestamp ON memories(timestamp DESC);
41+
42+
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
43+
key,
44+
content,
45+
content='memories',
46+
content_rowid='rowid',
47+
tokenize='unicode61'
48+
);
49+
50+
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
51+
INSERT INTO memories_fts(rowid, key, content) VALUES (new.rowid, new.key, new.content);
52+
END;
53+
54+
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
55+
INSERT INTO memories_fts(memories_fts, rowid, key, content) VALUES('delete', old.rowid, old.key, old.content);
56+
END;
57+
58+
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
59+
INSERT INTO memories_fts(memories_fts, rowid, key, content) VALUES('delete', old.rowid, old.key, old.content);
60+
INSERT INTO memories_fts(rowid, key, content) VALUES (new.rowid, new.key, new.content);
61+
END;
62+
""";
63+
cmd.ExecuteNonQuery();
64+
}
65+
66+
public async Task StoreAsync(string key, string content, MemoryCategory category = MemoryCategory.Core, CancellationToken ct = default)
67+
{
68+
var id = Guid.NewGuid().ToString();
69+
var timestamp = DateTimeOffset.UtcNow.ToString("O");
70+
71+
using var cmd = _connection.CreateCommand();
72+
cmd.CommandText = """
73+
INSERT INTO memories (id, key, content, category, timestamp)
74+
VALUES (@id, @key, @content, @category, @timestamp)
75+
ON CONFLICT(key) DO UPDATE SET
76+
content = @content,
77+
category = @category,
78+
timestamp = @timestamp
79+
""";
80+
cmd.Parameters.AddWithValue("@id", id);
81+
cmd.Parameters.AddWithValue("@key", key);
82+
cmd.Parameters.AddWithValue("@content", content);
83+
cmd.Parameters.AddWithValue("@category", (int)category);
84+
cmd.Parameters.AddWithValue("@timestamp", timestamp);
85+
86+
await cmd.ExecuteNonQueryAsync(ct);
87+
}
88+
89+
public async Task<MemoryEntry?> GetAsync(string key, CancellationToken ct = default)
90+
{
91+
using var cmd = _connection.CreateCommand();
92+
cmd.CommandText = "SELECT id, key, content, category, timestamp, session_id FROM memories WHERE key = @key";
93+
cmd.Parameters.AddWithValue("@key", key);
94+
95+
using var reader = await cmd.ExecuteReaderAsync(ct);
96+
if (await reader.ReadAsync(ct))
97+
{
98+
return ReadEntry(reader);
99+
}
100+
return null;
101+
}
102+
103+
public async Task<bool> DeleteAsync(string key, CancellationToken ct = default)
104+
{
105+
using var cmd = _connection.CreateCommand();
106+
cmd.CommandText = "DELETE FROM memories WHERE key = @key";
107+
cmd.Parameters.AddWithValue("@key", key);
108+
109+
var rows = await cmd.ExecuteNonQueryAsync(ct);
110+
return rows > 0;
111+
}
112+
113+
public async Task<IReadOnlyList<MemoryEntry>> SearchAsync(string query, int limit = 10, CancellationToken ct = default)
114+
{
115+
using var cmd = _connection.CreateCommand();
116+
cmd.CommandText = """
117+
SELECT m.id, m.key, m.content, m.category, m.timestamp, m.session_id,
118+
bm25(memories_fts) as score
119+
FROM memories_fts
120+
JOIN memories m ON memories_fts.rowid = m.rowid
121+
WHERE memories_fts MATCH @query
122+
ORDER BY score
123+
LIMIT @limit
124+
""";
125+
cmd.Parameters.AddWithValue("@query", query);
126+
cmd.Parameters.AddWithValue("@limit", limit);
127+
128+
var results = new List<MemoryEntry>();
129+
using var reader = await cmd.ExecuteReaderAsync(ct);
130+
while (await reader.ReadAsync(ct))
131+
{
132+
var id = reader.GetString(0);
133+
var key = reader.GetString(1);
134+
var content = reader.GetString(2);
135+
var category = (MemoryCategory)reader.GetInt32(3);
136+
var timestamp = DateTimeOffset.Parse(reader.GetString(4));
137+
var sessionId = reader.IsDBNull(5) ? null : reader.GetString(5);
138+
var score = reader.GetDouble(6);
139+
140+
results.Add(new MemoryEntry(id, key, content, category, timestamp, sessionId, score));
141+
}
142+
return results;
143+
}
144+
145+
public Task<IReadOnlyList<MemoryEntry>> VectorSearchAsync(string query, int limit = 5, CancellationToken ct = default)
146+
{
147+
// Vector search is a stub - returns empty results
148+
return Task.FromResult<IReadOnlyList<MemoryEntry>>(Array.Empty<MemoryEntry>());
149+
}
150+
151+
public async Task<IReadOnlyList<MemoryEntry>> ListAsync(MemoryCategory? category = null, int limit = 50, CancellationToken ct = default)
152+
{
153+
using var cmd = _connection.CreateCommand();
154+
155+
if (category.HasValue)
156+
{
157+
cmd.CommandText = """
158+
SELECT id, key, content, category, timestamp, session_id
159+
FROM memories
160+
WHERE category = @category
161+
ORDER BY timestamp DESC
162+
LIMIT @limit
163+
""";
164+
cmd.Parameters.AddWithValue("@category", (int)category.Value);
165+
}
166+
else
167+
{
168+
cmd.CommandText = """
169+
SELECT id, key, content, category, timestamp, session_id
170+
FROM memories
171+
ORDER BY timestamp DESC
172+
LIMIT @limit
173+
""";
174+
}
175+
cmd.Parameters.AddWithValue("@limit", limit);
176+
177+
var results = new List<MemoryEntry>();
178+
using var reader = await cmd.ExecuteReaderAsync(ct);
179+
while (await reader.ReadAsync(ct))
180+
{
181+
results.Add(ReadEntry(reader));
182+
}
183+
return results;
184+
}
185+
186+
public async Task<int> CountAsync(CancellationToken ct = default)
187+
{
188+
using var cmd = _connection.CreateCommand();
189+
cmd.CommandText = "SELECT COUNT(*) FROM memories";
190+
var result = await cmd.ExecuteScalarAsync(ct);
191+
return Convert.ToInt32(result);
192+
}
193+
194+
private static MemoryEntry ReadEntry(SqliteDataReader reader)
195+
{
196+
return new MemoryEntry(
197+
Id: reader.GetString(0),
198+
Key: reader.GetString(1),
199+
Content: reader.GetString(2),
200+
Category: (MemoryCategory)reader.GetInt32(3),
201+
Timestamp: DateTimeOffset.Parse(reader.GetString(4)),
202+
SessionId: reader.IsDBNull(5) ? null : reader.GetString(5)
203+
);
204+
}
205+
206+
public async ValueTask DisposeAsync()
207+
{
208+
if (!_disposed)
209+
{
210+
await _connection.CloseAsync();
211+
await _connection.DisposeAsync();
212+
_disposed = true;
213+
}
214+
}
215+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<ImplicitUsings>enable</ImplicitUsings>
5+
<Nullable>enable</Nullable>
6+
<IsPackable>false</IsPackable>
7+
</PropertyGroup>
8+
9+
<ItemGroup>
10+
<PackageReference Include="coverlet.collector" Version="6.0.4" />
11+
<PackageReference Include="FluentAssertions" Version="8.8.0" />
12+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
13+
<PackageReference Include="xunit" Version="2.9.3" />
14+
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
15+
</ItemGroup>
16+
17+
<ItemGroup>
18+
<Using Include="Xunit" />
19+
</ItemGroup>
20+
21+
<ItemGroup>
22+
<ProjectReference Include="..\..\src\ClawSharp.Memory\ClawSharp.Memory.csproj" />
23+
<ProjectReference Include="..\ClawSharp.TestHelpers\ClawSharp.TestHelpers.csproj" />
24+
</ItemGroup>
25+
26+
</Project>

0 commit comments

Comments
 (0)