Files
swarm-assistent/AssistentMemory.UserPrefs.cs
T
Leonid PershinandCursor fa73158e1c Ship Assistent 0.10: settings panel and UserPrefs with prompt weight.
Separate About-the-user memory (global + per-persona) from craft RAG, add tabbed settings with persona export/import and craft clear APIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 02:05:12 +03:00

344 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Data.Sqlite;
using Newtonsoft.Json.Linq;
using SwarmUI.Utils;
namespace Mrleo1nid.SwarmAssistent;
/// <summary>Facts about the human at the desk — global + per-persona, separate from craft RAG.</summary>
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<JObject> rows = [];
string wantScope = (scope ?? "").Trim().ToLowerInvariant();
string wantPersona = AssistentConfig.SafeId(personaId) ?? "";
using SqliteCommand cmd = _conn.CreateCommand();
List<string> 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());
}
}
/// <summary>Select prefs for prompt injection according to weight (0=off, &lt;1=short, 1=full, &gt;1=must).</summary>
public List<JObject> SelectUserPrefsForPrompt(string personaId, double weight, int maxRows)
{
if (weight <= 0)
{
return [];
}
string pid = AssistentConfig.SafeId(personaId) ?? "neutral";
JArray all = ListUserPrefs(null, pid, 500);
List<JObject> list = all.OfType<JObject>().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<JObject> 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<bool>() == true ? " ★" : "";
sb.AppendLine($"- [{sc}] {key}{pin}: {text}");
}
return sb.ToString().TrimEnd();
}
/// <summary>Batch-delete craft vector rows (never bundled). Returns deleted count.</summary>
public int ClearCraftMemory(string scope = null, string kind = null, string persona = null)
{
lock (_lock)
{
EnsureOpen();
using SqliteCommand cmd = _conn.CreateCommand();
List<string> 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();
}
}
}