using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Data.Sqlite;
using Newtonsoft.Json.Linq;
using SwarmUI.Utils;
namespace Mrleo1nid.SwarmAssistent;
/// Facts about the human at the desk — global + per-persona, separate from craft RAG.
public sealed partial class AssistentMemory
{
const string MetaTasteMigrated = "user_prefs_taste_migrated";
void EnsureUserPrefsSchema()
{
Exec(
"""
CREATE TABLE IF NOT EXISTS user_prefs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL,
text TEXT NOT NULL,
scope TEXT NOT NULL DEFAULT 'global',
persona_id TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'user',
pinned INTEGER NOT NULL DEFAULT 0,
updated INTEGER NOT NULL,
UNIQUE(key, scope, persona_id)
);
CREATE INDEX IF NOT EXISTS idx_user_prefs_scope ON user_prefs(scope, persona_id);
""");
MigrateTasteToUserPrefsOnce();
}
void MigrateTasteToUserPrefsOnce()
{
if (GetMeta(MetaTasteMigrated) == "1")
{
return;
}
try
{
JObject taste = GetKvObject(KvTaste);
if (taste is not null && taste.Count > 0)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
void AddList(string prefix, JToken arr)
{
if (arr is not JArray a)
{
return;
}
int i = 0;
foreach (JToken t in a)
{
string text = (t?.ToString() ?? "").Trim();
if (string.IsNullOrWhiteSpace(text))
{
continue;
}
UpsertUserPrefUnlocked($"{prefix}_{i++}", text, "global", "", "migrated_taste", pinned: false, now);
}
}
AddList("like", taste["likes"]);
AddList("avoid", taste["avoid"]);
AddList("style", taste["styles"]);
string notes = (taste["notes"]?.ToString() ?? "").Trim();
if (!string.IsNullOrWhiteSpace(notes))
{
UpsertUserPrefUnlocked("notes", notes, "global", "", "migrated_taste", pinned: false, now);
}
}
}
catch (Exception ex)
{
Logs.Debug($"AssistentMemory taste→user_prefs: {ex.Message}");
}
SetMeta(MetaTasteMigrated, "1");
}
static string NormalizePrefScope(string scope)
{
string s = (scope ?? "").Trim().ToLowerInvariant();
return s is "persona" or "personal" or "agent" ? "persona" : "global";
}
static string NormalizePrefPersona(string scope, string personaId)
{
if (NormalizePrefScope(scope) == "global")
{
return "";
}
return AssistentConfig.SafeId(personaId) ?? "neutral";
}
public JArray ListUserPrefs(string scope = null, string personaId = null, int limit = 200)
{
lock (_lock)
{
EnsureOpen();
List rows = [];
string wantScope = (scope ?? "").Trim().ToLowerInvariant();
string wantPersona = AssistentConfig.SafeId(personaId) ?? "";
using SqliteCommand cmd = _conn.CreateCommand();
List where = [];
if (wantScope is "global" or "shared" or "common")
{
where.Add("scope = 'global'");
}
else if (wantScope is "persona" or "personal")
{
where.Add("scope = 'persona'");
if (!string.IsNullOrWhiteSpace(wantPersona))
{
where.Add("persona_id = $persona");
cmd.Parameters.AddWithValue("$persona", wantPersona);
}
}
else if (!string.IsNullOrWhiteSpace(wantPersona))
{
// Prompt path: global ∪ this persona
where.Add("(scope = 'global' OR (scope = 'persona' AND persona_id = $persona))");
cmd.Parameters.AddWithValue("$persona", wantPersona);
}
string whereSql = where.Count > 0 ? "WHERE " + string.Join(" AND ", where) : "";
cmd.CommandText =
$"""
SELECT id, key, text, scope, persona_id, source, pinned, updated
FROM user_prefs
{whereSql}
ORDER BY pinned DESC, updated DESC
LIMIT $lim
""";
cmd.Parameters.AddWithValue("$lim", Math.Clamp(limit, 1, 2000));
using SqliteDataReader reader = cmd.ExecuteReader();
while (reader.Read())
{
string sc = reader.GetString(3);
string pid = reader.IsDBNull(4) ? "" : reader.GetString(4) ?? "";
rows.Add(new JObject
{
["id"] = reader.GetInt64(0),
["key"] = reader.GetString(1),
["text"] = reader.GetString(2),
["scope"] = sc,
["persona"] = string.IsNullOrEmpty(pid) ? "" : pid,
["persona_id"] = pid,
["source"] = reader.GetString(5),
["pinned"] = !reader.IsDBNull(6) && reader.GetInt64(6) != 0,
["updated"] = reader.IsDBNull(7) ? 0 : reader.GetInt64(7),
});
}
return new JArray(rows);
}
}
public JObject UpsertUserPref(string key, string text, string scope = "global", string personaId = null, string source = "user", bool? pinned = null)
{
lock (_lock)
{
EnsureOpen();
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
UpsertUserPrefUnlocked(key, text, scope, personaId, source, pinned ?? false, now);
return new JObject
{
["key"] = (key ?? "").Trim(),
["text"] = (text ?? "").Trim(),
["scope"] = NormalizePrefScope(scope),
["persona_id"] = NormalizePrefPersona(scope, personaId),
["source"] = string.IsNullOrWhiteSpace(source) ? "user" : source.Trim().ToLowerInvariant(),
["pinned"] = pinned ?? false,
["updated"] = now,
};
}
}
void UpsertUserPrefUnlocked(string key, string text, string scope, string personaId, string source, bool pinned, long updated)
{
key = (key ?? "").Trim();
text = (text ?? "").Trim();
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(text))
{
throw new ArgumentException("key and text required");
}
if (key.Length > 120)
{
key = key[..120];
}
string sc = NormalizePrefScope(scope);
string pid = NormalizePrefPersona(sc, personaId);
string src = string.IsNullOrWhiteSpace(source) ? "user" : source.Trim().ToLowerInvariant();
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText =
"""
INSERT INTO user_prefs(key, text, scope, persona_id, source, pinned, updated)
VALUES ($key, $text, $scope, $persona, $source, $pinned, $upd)
ON CONFLICT(key, scope, persona_id) DO UPDATE SET
text = excluded.text,
source = excluded.source,
pinned = excluded.pinned,
updated = excluded.updated
""";
cmd.Parameters.AddWithValue("$key", key);
cmd.Parameters.AddWithValue("$text", text);
cmd.Parameters.AddWithValue("$scope", sc);
cmd.Parameters.AddWithValue("$persona", pid);
cmd.Parameters.AddWithValue("$source", src);
cmd.Parameters.AddWithValue("$pinned", pinned ? 1 : 0);
cmd.Parameters.AddWithValue("$upd", updated);
cmd.ExecuteNonQuery();
}
public bool ForgetUserPref(string key, string scope = "global", string personaId = null)
{
lock (_lock)
{
EnsureOpen();
string sc = NormalizePrefScope(scope);
string pid = NormalizePrefPersona(sc, personaId);
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText = "DELETE FROM user_prefs WHERE key = $key AND scope = $scope AND persona_id = $persona";
cmd.Parameters.AddWithValue("$key", (key ?? "").Trim());
cmd.Parameters.AddWithValue("$scope", sc);
cmd.Parameters.AddWithValue("$persona", pid);
return cmd.ExecuteNonQuery() > 0;
}
}
public int ClearUserPrefs(string scope = null, string personaId = null)
{
lock (_lock)
{
EnsureOpen();
string want = (scope ?? "all").Trim().ToLowerInvariant();
using SqliteCommand cmd = _conn.CreateCommand();
if (want is "global" or "shared")
{
cmd.CommandText = "DELETE FROM user_prefs WHERE scope = 'global'";
}
else if (want is "persona" or "personal")
{
string pid = AssistentConfig.SafeId(personaId) ?? "";
if (string.IsNullOrWhiteSpace(pid))
{
cmd.CommandText = "DELETE FROM user_prefs WHERE scope = 'persona'";
}
else
{
cmd.CommandText = "DELETE FROM user_prefs WHERE scope = 'persona' AND persona_id = $persona";
cmd.Parameters.AddWithValue("$persona", pid);
}
}
else
{
cmd.CommandText = "DELETE FROM user_prefs";
}
return cmd.ExecuteNonQuery();
}
}
public int CountUserPrefs()
{
lock (_lock)
{
EnsureOpen();
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM user_prefs";
return Convert.ToInt32(cmd.ExecuteScalar());
}
}
/// Select prefs for prompt injection according to weight (0=off, <1=short, 1=full, >1=must).
public List SelectUserPrefsForPrompt(string personaId, double weight, int maxRows)
{
if (weight <= 0)
{
return [];
}
string pid = AssistentConfig.SafeId(personaId) ?? "neutral";
JArray all = ListUserPrefs(null, pid, 500);
List list = all.OfType().ToList();
maxRows = Math.Clamp(maxRows, 1, 40);
int take = weight >= 1
? maxRows
: Math.Max(3, (int)Math.Ceiling(maxRows * weight));
return list.Take(take).ToList();
}
public string FormatUserPrefsBlock(string personaId, double weight, int maxRows)
{
List rows = SelectUserPrefsForPrompt(personaId, weight, maxRows);
if (rows.Count == 0 || weight <= 0)
{
return null;
}
var sb = new System.Text.StringBuilder();
sb.AppendLine("## About the user");
if (weight > 1.05)
{
sb.AppendLine("MUST respect these preferences unless the current user message overrides them this turn.");
}
sb.AppendLine("Facts about the human (global = every persona; persona = this agent only):");
foreach (JObject row in rows)
{
string sc = row["scope"]?.ToString() ?? "global";
string key = row["key"]?.ToString() ?? "";
string text = row["text"]?.ToString() ?? "";
string pin = row["pinned"]?.Value() == true ? " ★" : "";
sb.AppendLine($"- [{sc}] {key}{pin}: {text}");
}
return sb.ToString().TrimEnd();
}
/// Batch-delete craft vector rows (never bundled). Returns deleted count.
public int ClearCraftMemory(string scope = null, string kind = null, string persona = null)
{
lock (_lock)
{
EnsureOpen();
using SqliteCommand cmd = _conn.CreateCommand();
List where = ["source != 'bundled'"];
string wantScope = (scope ?? "").Trim().ToLowerInvariant();
if (wantScope is "shared" or "common" or "global")
{
where.Add("persona = ''");
}
else if (wantScope is "personal" || !string.IsNullOrWhiteSpace(persona))
{
string pid = NormalizePersona(persona);
where.Add("persona = $persona");
cmd.Parameters.AddWithValue("$persona", pid);
}
string k = (kind ?? "").Trim().ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(k) && k != "all")
{
where.Add("kind = $kind");
cmd.Parameters.AddWithValue("$kind", k);
}
cmd.CommandText = "DELETE FROM memories WHERE " + string.Join(" AND ", where);
return cmd.ExecuteNonQuery();
}
}
}