Ship Assistent disk persist, park LLM, and memory UI cleanup.

Split the extension into partials, persist chats on the data volume, park/warm the chat model around Generate, and drop dual raw/persona dump paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-22 01:03:40 +03:00
co-authored by Cursor
parent 880e2dbea2
commit 8702b64e12
12 changed files with 1192 additions and 114 deletions
+393 -40
View File
@@ -4,6 +4,7 @@ using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Newtonsoft.Json.Linq;
@@ -14,10 +15,31 @@ namespace Mrleo1nid.SwarmAssistent;
/// <summary>Local SQLite vector memory with Ollama /api/embed.
/// Two layers: shared (persona='') is visible to every personality; personal (persona=id)
/// is not written back to shared. On retrieve, personal overwrites shared on the same kind+key.</summary>
public sealed class AssistentMemory : IDisposable
public sealed partial class AssistentMemory : IDisposable
{
public const string SharedPersona = "";
public sealed class RetrieveOptions
{
public int TopK { get; set; } = 10;
public float MinScore { get; set; } = 0.32f;
public string KindFilter { get; set; }
public IReadOnlyDictionary<string, int> Quotas { get; set; }
public bool ApplyQuotas { get; set; } = true;
}
static readonly Dictionary<string, int> DefaultQuotas = new(StringComparer.OrdinalIgnoreCase)
{
["card"] = 3,
["lora"] = 3,
["pitfall"] = 3,
["path"] = 2,
["note"] = 4,
["model"] = 2,
["aspect"] = 1,
};
readonly string _dataRoot;
readonly string _dbPath;
readonly HttpClient _http;
readonly object _lock = new();
@@ -28,7 +50,8 @@ public sealed class AssistentMemory : IDisposable
public AssistentMemory(string dataRoot, HttpClient http, string defaultEmbedModel = "nomic-embed-text")
{
string dir = Path.Combine(dataRoot ?? ".", "Assistent", "memory");
_dataRoot = string.IsNullOrWhiteSpace(dataRoot) ? "." : dataRoot;
string dir = Path.Combine(_dataRoot, "Assistent", "memory");
Directory.CreateDirectory(dir);
_dbPath = Path.Combine(dir, "assistent.sqlite");
_http = http;
@@ -63,6 +86,8 @@ public sealed class AssistentMemory : IDisposable
}
_conn = new SqliteConnection($"Data Source={_dbPath}");
_conn.Open();
TryPragma("journal_mode=WAL");
TryPragma("busy_timeout=5000");
using (SqliteCommand cmd = _conn.CreateCommand())
{
cmd.CommandText =
@@ -96,6 +121,23 @@ public sealed class AssistentMemory : IDisposable
""";
idx.ExecuteNonQuery();
}
EnsureFts();
try
{
EnsureTagsSchema();
}
catch (Exception ex)
{
Logs.Debug($"AssistentMemory tags schema: {ex.Message}");
}
try
{
EnsureStoreSchema();
}
catch (Exception ex)
{
Logs.Debug($"AssistentMemory store schema: {ex.Message}");
}
_embedModel = GetMeta("embed_model") ?? _embedModel;
_ = int.TryParse(GetMeta("dims"), out _dims);
_ = int.TryParse(GetMeta("seed_version"), out _seedVersion);
@@ -154,6 +196,89 @@ public sealed class AssistentMemory : IDisposable
Logs.Debug("AssistentMemory: migrated sqlite to shared+personal persona column (existing rows → shared)");
}
void TryPragma(string pragma)
{
try
{
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText = "PRAGMA " + pragma;
cmd.ExecuteNonQuery();
}
catch (Exception ex)
{
Logs.Debug($"AssistentMemory PRAGMA {pragma}: {ex.Message}");
}
}
bool TableExists(string name)
{
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type IN ('table','view') AND name = $n LIMIT 1";
cmd.Parameters.AddWithValue("$n", name);
return cmd.ExecuteScalar() is not null;
}
void Exec(string sql)
{
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
void EnsureFts()
{
try
{
Exec(
"""
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
key,
text,
tokenize = 'unicode61 remove_diacritics 2'
);
""");
Exec(
"""
CREATE TRIGGER IF NOT EXISTS memories_fts_ai AFTER INSERT ON memories BEGIN
INSERT INTO memories_fts(rowid, key, text) VALUES (new.id, new.key, new.text);
END;
""");
Exec(
"""
CREATE TRIGGER IF NOT EXISTS memories_fts_ad AFTER DELETE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid) VALUES('delete', old.id);
END;
""");
Exec(
"""
CREATE TRIGGER IF NOT EXISTS memories_fts_au AFTER UPDATE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid) VALUES('delete', old.id);
INSERT INTO memories_fts(rowid, key, text) VALUES (new.id, new.key, new.text);
END;
""");
int mem = 0, fts = 0;
using (SqliteCommand c = _conn.CreateCommand())
{
c.CommandText = "SELECT COUNT(*) FROM memories";
mem = Convert.ToInt32(c.ExecuteScalar());
}
using (SqliteCommand c = _conn.CreateCommand())
{
c.CommandText = "SELECT COUNT(*) FROM memories_fts";
fts = Convert.ToInt32(c.ExecuteScalar());
}
if (mem != fts)
{
Exec("DELETE FROM memories_fts");
Exec("INSERT INTO memories_fts(rowid, key, text) SELECT id, key, text FROM memories");
}
}
catch (Exception ex)
{
Logs.Debug($"AssistentMemory FTS5 unavailable, cosine-only: {ex.Message}");
}
}
string GetMeta(string key)
{
using SqliteCommand cmd = _conn.CreateCommand();
@@ -268,6 +393,7 @@ public sealed class AssistentMemory : IDisposable
List<JObject> docs = config.LoadMemorySeedDocs();
if (docs.Count == 0)
{
TryIndexTags();
return;
}
@@ -287,6 +413,7 @@ public sealed class AssistentMemory : IDisposable
}
if (!missing)
{
TryIndexTags();
return;
}
}
@@ -299,6 +426,7 @@ public sealed class AssistentMemory : IDisposable
catch (Exception ex)
{
Logs.Debug($"AssistentMemory seed defer (embed unavailable): {ex.Message}");
TryIndexTags();
return;
}
@@ -356,6 +484,7 @@ public sealed class AssistentMemory : IDisposable
Logs.Debug($"AssistentMemory seed item {kind}/{key}: {ex.Message}");
}
}
TryIndexTags();
}
public void Upsert(string kind, string key, string text, string source, JToken meta, float[] embedding, string persona = null)
@@ -428,29 +557,8 @@ public sealed class AssistentMemory : IDisposable
}
}
/// <summary>Retrieve shared + the given persona chain. Personal overwrites shared (and parent personas) on kind+key.</summary>
public async Task<JArray> RetrieveAsync(string baseUrl, string query, int topK = 10, string modelOverride = null, IEnumerable<string> personaChain = null)
static Dictionary<string, int> PersonaRankMap(IEnumerable<string> personaChain)
{
if (string.IsNullOrWhiteSpace(query))
{
return [];
}
lock (_lock)
{
EnsureOpen();
}
string model = string.IsNullOrWhiteSpace(modelOverride) ? _embedModel : modelOverride;
float[] q;
try
{
q = await EmbedAsync(baseUrl, model, query);
}
catch (Exception ex)
{
Logs.Debug($"AssistentMemory retrieve embed: {ex.Message}");
return [];
}
Dictionary<string, int> rank = new(StringComparer.OrdinalIgnoreCase)
{
[SharedPersona] = 0,
@@ -465,46 +573,219 @@ public sealed class AssistentMemory : IDisposable
}
rank[p] = i++;
}
return rank;
}
List<(float score, int personaRank, int sourceRank, JObject row)> scored = [];
static string BuildFtsMatch(string query)
{
if (string.IsNullOrWhiteSpace(query))
{
return null;
}
HashSet<string> tokens = new(StringComparer.OrdinalIgnoreCase);
foreach (Match m in Regex.Matches(query, @"[\p{L}\p{N}_-]{2,}"))
{
string t = m.Value.Replace("\"", "").Trim('_', '-');
if (t.Length >= 2)
{
tokens.Add(t);
}
string spaced = t.Replace('_', ' ').Replace('-', ' ');
if (!string.Equals(spaced, t, StringComparison.OrdinalIgnoreCase) && spaced.Length >= 2)
{
foreach (string p in spaced.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
if (p.Length >= 2)
{
tokens.Add(p);
}
}
}
if (tokens.Count >= 12)
{
break;
}
}
if (tokens.Count == 0)
{
return null;
}
return string.Join(" OR ", tokens.Select(t => $"\"{t}\""));
}
Dictionary<long, int> FtsRowRanks(string match, int limit)
{
Dictionary<long, int> ranks = [];
if (string.IsNullOrWhiteSpace(match) || !TableExists("memories_fts"))
{
return ranks;
}
try
{
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText = "SELECT rowid FROM memories_fts WHERE memories_fts MATCH $q ORDER BY rank LIMIT $lim";
cmd.Parameters.AddWithValue("$q", match);
cmd.Parameters.AddWithValue("$lim", limit);
using SqliteDataReader reader = cmd.ExecuteReader();
int r = 0;
while (reader.Read())
{
ranks[reader.GetInt64(0)] = r++;
}
}
catch (Exception ex)
{
Logs.Debug($"AssistentMemory FTS match: {ex.Message}");
}
return ranks;
}
static float KeyBoost(string query, string key)
{
if (string.IsNullOrWhiteSpace(query))
{
return 0;
}
string q = query.Replace('_', ' ');
string k = (key ?? "").Replace('_', ' ');
if (k.Length >= 3 && q.Contains(k, StringComparison.OrdinalIgnoreCase))
{
return 0.22f;
}
if (k.Length >= 3 && k.Contains(q.Trim(), StringComparison.OrdinalIgnoreCase) && q.Trim().Length >= 4)
{
return 0.12f;
}
return 0;
}
static JArray ApplyQuotas(IEnumerable<JObject> ordered, int topK, IReadOnlyDictionary<string, int> quotas)
{
Dictionary<string, int> used = new(StringComparer.OrdinalIgnoreCase);
List<JObject> picked = [];
List<JObject> overflow = [];
foreach (JObject row in ordered)
{
string kind = row["kind"]?.ToString() ?? "note";
int cap = 2;
if (quotas is not null && quotas.TryGetValue(kind, out int q))
{
cap = q;
}
else if (DefaultQuotas.TryGetValue(kind, out int d))
{
cap = d;
}
used.TryGetValue(kind, out int n);
if (n < cap)
{
picked.Add(row);
used[kind] = n + 1;
}
else
{
overflow.Add(row);
}
if (picked.Count >= topK)
{
return new JArray(picked);
}
}
foreach (JObject row in overflow)
{
if (picked.Count >= topK)
{
break;
}
picked.Add(row);
}
return new JArray(picked);
}
/// <summary>Retrieve shared + the given persona chain. Hybrid FTS + cosine; personal overwrites shared on kind+key.</summary>
public async Task<JArray> RetrieveAsync(string baseUrl, string query, int topK = 10, string modelOverride = null, IEnumerable<string> personaChain = null, RetrieveOptions options = null)
{
options ??= new RetrieveOptions { TopK = topK };
if (options.TopK <= 0)
{
options.TopK = topK;
}
if (string.IsNullOrWhiteSpace(query))
{
return [];
}
lock (_lock)
{
EnsureOpen();
}
string model = string.IsNullOrWhiteSpace(modelOverride) ? _embedModel : modelOverride;
float[] qvec = null;
try
{
qvec = await EmbedAsync(baseUrl, model, query);
}
catch (Exception ex)
{
Logs.Debug($"AssistentMemory retrieve embed: {ex.Message}");
}
Dictionary<string, int> rank = PersonaRankMap(personaChain);
string kindFilter = string.IsNullOrWhiteSpace(options.KindFilter) ? null : options.KindFilter.Trim().ToLowerInvariant();
string ftsMatch = BuildFtsMatch(query);
List<(float hybrid, float cosine, int personaRank, int sourceRank, bool fts, JObject row)> scored = [];
lock (_lock)
{
EnsureOpen();
Dictionary<long, int> ftsRanks = FtsRowRanks(ftsMatch, Math.Max(40, options.TopK * 4));
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText = "SELECT kind, key, text, source, meta_json, embedding, persona FROM memories WHERE embedding IS NOT NULL";
cmd.CommandText = "SELECT id, kind, key, text, source, embedding, persona FROM memories";
using SqliteDataReader reader = cmd.ExecuteReader();
while (reader.Read())
{
long id = reader.GetInt64(0);
string kind = reader.GetString(1);
if (kindFilter is not null && !string.Equals(kind, kindFilter, StringComparison.OrdinalIgnoreCase))
{
continue;
}
string persona = reader.IsDBNull(6) ? SharedPersona : reader.GetString(6) ?? SharedPersona;
if (!rank.TryGetValue(persona, out int personaRank))
{
continue;
}
float[] emb = BytesToFloats(reader.IsDBNull(5) ? null : (byte[])reader.GetValue(5));
float score = Cosine(q, emb);
if (float.IsNegativeInfinity(score))
string key = reader.GetString(2);
string text = reader.GetString(3);
string source = reader.GetString(4);
float cosine = 0;
if (qvec is not null && !reader.IsDBNull(5))
{
continue;
cosine = Cosine(qvec, BytesToFloats((byte[])reader.GetValue(5)));
if (float.IsNegativeInfinity(cosine))
{
cosine = 0;
}
}
string kind = reader.GetString(0);
string key = reader.GetString(1);
string source = reader.GetString(3);
bool ftsHit = ftsRanks.TryGetValue(id, out int ftsRank);
float ftsBoost = ftsHit ? 0.28f * (1f - (ftsRank / 50f)) : 0;
float hybrid = cosine + ftsBoost + KeyBoost(query, key);
bool shared = persona == SharedPersona;
scored.Add((score, personaRank, SourceRank(source), new JObject
scored.Add((hybrid, cosine, personaRank, SourceRank(source), ftsHit, new JObject
{
["kind"] = kind,
["key"] = key,
["text"] = reader.GetString(2),
["text"] = text,
["source"] = source,
["scope"] = shared ? "shared" : "personal",
["persona"] = shared ? "shared" : persona,
["score"] = Math.Round(score, 4),
["score"] = Math.Round(hybrid, 4),
["cosine"] = Math.Round(cosine, 4),
["fts"] = ftsHit,
}));
}
}
// Personal (and later parents) overwrite shared on the same kind+key; user beats bundled.
Dictionary<string, (float score, int personaRank, int sourceRank, JObject row)> best = new(StringComparer.OrdinalIgnoreCase);
Dictionary<string, (float hybrid, float cosine, int personaRank, int sourceRank, bool fts, JObject row)> best = new(StringComparer.OrdinalIgnoreCase);
foreach (var item in scored)
{
string id = $"{item.row["kind"]}\n{item.row["key"]}";
@@ -512,7 +793,7 @@ public sealed class AssistentMemory : IDisposable
{
if (item.personaRank < cur.personaRank
|| (item.personaRank == cur.personaRank && item.sourceRank < cur.sourceRank)
|| (item.personaRank == cur.personaRank && item.sourceRank == cur.sourceRank && item.score <= cur.score))
|| (item.personaRank == cur.personaRank && item.sourceRank == cur.sourceRank && item.hybrid <= cur.hybrid))
{
continue;
}
@@ -520,7 +801,79 @@ public sealed class AssistentMemory : IDisposable
best[id] = item;
}
return new JArray(best.Values.OrderByDescending(s => s.score).Take(Math.Clamp(topK, 1, 30)).Select(s => s.row));
float min = options.MinScore;
IEnumerable<JObject> ordered = best.Values
.Where(s => s.fts || s.hybrid >= min || s.cosine >= min)
.OrderByDescending(s => s.hybrid)
.Select(s => s.row);
int k = Math.Clamp(options.TopK, 1, 30);
if (options.ApplyQuotas)
{
return ApplyQuotas(ordered, k, options.Quotas ?? DefaultQuotas);
}
return new JArray(ordered.Take(k));
}
/// <summary>Exact kind+key read with the same personal-over-shared overlay as retrieve.</summary>
public JObject Get(string kind, string key, IEnumerable<string> personaChain = null)
{
kind = (kind ?? "note").Trim().ToLowerInvariant();
key = (key ?? "").Trim();
if (string.IsNullOrWhiteSpace(key))
{
return null;
}
Dictionary<string, int> rank = PersonaRankMap(personaChain);
JObject best = null;
int bestPersona = -1, bestSource = -1;
lock (_lock)
{
EnsureOpen();
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText = "SELECT kind, key, text, source, persona, updated FROM memories WHERE kind = $kind AND key = $key";
cmd.Parameters.AddWithValue("$kind", kind);
cmd.Parameters.AddWithValue("$key", key);
using SqliteDataReader reader = cmd.ExecuteReader();
while (reader.Read())
{
string persona = reader.IsDBNull(4) ? SharedPersona : reader.GetString(4) ?? SharedPersona;
if (!rank.TryGetValue(persona, out int personaRank))
{
continue;
}
int src = SourceRank(reader.GetString(3));
if (best is not null && (personaRank < bestPersona || (personaRank == bestPersona && src <= bestSource)))
{
continue;
}
bestPersona = personaRank;
bestSource = src;
bool shared = persona == SharedPersona;
best = new JObject
{
["kind"] = reader.GetString(0),
["key"] = reader.GetString(1),
["text"] = reader.GetString(2),
["source"] = reader.GetString(3),
["scope"] = shared ? "shared" : "personal",
["persona"] = shared ? "shared" : persona,
["updated"] = reader.IsDBNull(5) ? 0 : reader.GetInt64(5),
};
}
}
return best;
}
public async Task<JArray> SearchAsync(string baseUrl, string query, string kind, int topK, string modelOverride, IEnumerable<string> personaChain)
{
return await RetrieveAsync(baseUrl, query, topK, modelOverride, personaChain, new RetrieveOptions
{
TopK = Math.Clamp(topK, 1, 30),
MinScore = 0.18f,
KindFilter = kind,
ApplyQuotas = false,
});
}
public JArray ListAll(int limit = 200)