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>
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
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, <1=short, 1=full, >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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user