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:
Leonid Pershin
2026-08-22 02:05:12 +03:00
co-authored by Cursor
parent cf89348f85
commit fa73158e1c
18 changed files with 2081 additions and 191 deletions
+259 -11
View File
@@ -715,6 +715,145 @@ public sealed class AssistentConfig
/// <summary>Exact (KV) memory: bundled base → persona overlays → disk overlays. Persona keys overwrite base.</summary>
public JObject LoadExact(string personaId) => MergeJsonLayers("exact.json", LayerRoots(personaId));
/// <summary>DeepMerge sparse keys into Assistent/_base/&lt;fileName&gt; (disk overlay only).</summary>
public JObject MergeOverlayBaseJson(string fileName, JObject sparse)
{
string safe = Path.GetFileName(fileName ?? "");
if (string.IsNullOrWhiteSpace(safe) || !safe.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException("invalid overlay base json name");
}
string dir = Path.Combine(_overlayRoot, "_base");
Directory.CreateDirectory(dir);
string path = Path.Combine(dir, safe);
JObject existing = TryReadJson(path) ?? new JObject();
JObject merged = DeepMerge(existing, sparse ?? new JObject());
File.WriteAllText(path, merged.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
return merged;
}
/// <summary>Export a shareable persona pack (merged shelves + controls + personal memory-seed).</summary>
public JObject ExportPersonaPack(string personaId)
{
string id = SafeId(personaId) ?? throw new ArgumentException("invalid persona id");
JObject shelves = LoadIdentityParts(id);
JObject controls = LoadControlsSchema(id);
JObject controlValues = LoadControlValues(id);
JArray seed = [];
foreach (JObject doc in LoadMemorySeedDocs())
{
string persona = AssistentMemory.NormalizePersona(doc["persona"]?.ToString());
if (!string.Equals(persona, id, StringComparison.OrdinalIgnoreCase))
{
continue;
}
string kind = doc["kind"]?.ToString() ?? "note";
string key = doc["key"]?.ToString() ?? "";
string text = doc["text"]?.ToString() ?? "";
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(text))
{
continue;
}
seed.Add(new JObject
{
["kind"] = kind,
["key"] = key,
["text"] = text,
});
}
JObject metaShelf = shelves["persona"] as JObject ?? new JObject();
return new JObject
{
["format"] = "swarm-assistent-persona",
["version"] = 1,
["id"] = id,
["title"] = metaShelf["title"]?.ToString() ?? id,
["shelves"] = shelves,
["exact_controls"] = controlValues,
["controls_schema"] = controls,
["memory_seed"] = seed,
};
}
/// <summary>Import pack into overlay personas/&lt;id&gt;. Never overwrites bundled files.</summary>
public JObject ImportPersonaPack(JObject pack, string newId = null, bool overwriteOverlay = false)
{
if (pack is null || !string.Equals(pack["format"]?.ToString(), "swarm-assistent-persona", StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException("invalid persona pack format");
}
string requested = SafeId(newId) ?? SafeId(pack["id"]?.ToString());
if (requested is null)
{
throw new ArgumentException("invalid persona id");
}
string id = requested;
if (IsBundledPersona(id) && !IsOverlayPersona(id))
{
// Force rename away from bundled id
id = SafeId(id + "_import") ?? throw new InvalidOperationException("cannot import over bundled id — pick a new id");
if (IsBundledPersona(id) || (IsOverlayPersona(id) && !overwriteOverlay))
{
throw new InvalidOperationException($"id '{requested}' is bundled — pass new_id");
}
}
if (IsOverlayPersona(id) && !overwriteOverlay)
{
throw new InvalidOperationException($"overlay persona '{id}' already exists — pass overwrite or new_id");
}
string dest = Path.Combine(_overlayRoot, "personas", id);
Directory.CreateDirectory(dest);
if (pack["shelves"] is JObject shelves)
{
foreach (JProperty prop in shelves.Properties())
{
string fileName = prop.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase)
? Path.GetFileName(prop.Name)
: prop.Name + ".json";
if (!IsWritableShelfName(fileName) && !string.Equals(fileName, "persona.json", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (prop.Value is JObject jo)
{
File.WriteAllText(Path.Combine(dest, fileName),
jo.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
}
}
}
string personaPath = Path.Combine(dest, "persona.json");
JObject meta = TryReadJson(personaPath) ?? new JObject();
meta["id"] = id;
if (!string.IsNullOrWhiteSpace(pack["title"]?.ToString()))
{
meta["title"] = pack["title"]?.ToString();
}
File.WriteAllText(personaPath, meta.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
if (pack["controls_schema"] is JObject schema && schema.Count > 0)
{
File.WriteAllText(Path.Combine(dest, "controls.json"),
schema.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
}
if (pack["exact_controls"] is JObject ctrl && ctrl.Count > 0)
{
File.WriteAllText(Path.Combine(dest, "exact.json"),
new JObject { ["controls"] = ctrl }.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
}
if (pack["memory_seed"] is JArray seedArr && seedArr.Count > 0)
{
string seedDir = Path.Combine(dest, "memory-seed");
Directory.CreateDirectory(seedDir);
File.WriteAllText(Path.Combine(seedDir, "imported.json"),
seedArr.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
}
return new JObject
{
["id"] = id,
["title"] = meta["title"]?.ToString() ?? id,
["source"] = "overlay",
};
}
public JObject LoadModelProfile(string personaId)
{
JObject assistant = LoadAssistant(personaId);
@@ -962,7 +1101,37 @@ public sealed class AssistentConfig
return char.ToUpperInvariant(key[0]) + key[1..];
}
public string RenderIdentityBlock(string personaId)
static readonly string[] DefaultIdentityAlwaysShelves =
["persona", "voice", "rules", "likes", "dislikes"];
/// <summary>Shelves always injected into the system prompt. Lore shelves load via persona_read hop.</summary>
public HashSet<string> IdentityAlwaysShelves(string personaId)
{
HashSet<string> set = new(StringComparer.OrdinalIgnoreCase);
JObject asst = LoadAssistant(SafeId(personaId) ?? "neutral") ?? new JObject();
if (asst["identity_always_shelves"] is JArray arr && arr.Count > 0)
{
foreach (JToken t in arr)
{
string name = SafeId(t?.ToString()) ?? t?.ToString()?.Trim();
if (!string.IsNullOrWhiteSpace(name))
{
set.Add(name);
}
}
}
if (set.Count == 0)
{
foreach (string s in DefaultIdentityAlwaysShelves)
{
set.Add(s);
}
}
return set;
}
/// <summary>Render always-on identity (voice/taste). Pass includeAllShelves for author pack / persona_read.</summary>
public string RenderIdentityBlock(string personaId, bool includeAllShelves = false, IEnumerable<string> onlyShelves = null)
{
string id = SafeId(personaId) ?? "neutral";
JObject parts = LoadIdentityParts(id);
@@ -976,12 +1145,26 @@ public sealed class AssistentConfig
sb.AppendLine($"*{tagline}*");
}
HashSet<string> allow = null;
if (onlyShelves is not null)
{
allow = new HashSet<string>(onlyShelves.Where(s => !string.IsNullOrWhiteSpace(s)), StringComparer.OrdinalIgnoreCase);
}
else if (!includeAllShelves)
{
allow = IdentityAlwaysShelves(id);
}
foreach (JProperty prop in parts.Properties())
{
if (prop.Name is "persona" or "extra")
{
continue;
}
if (allow is not null && !allow.Contains(prop.Name))
{
continue;
}
if (prop.Value is not JObject shelf || !shelf.Properties().Any())
{
continue;
@@ -991,22 +1174,87 @@ public sealed class AssistentConfig
AppendTokenMarkdown(sb, shelf, 0);
}
string controlsBlock = RenderControlsBlock(id);
if (!string.IsNullOrWhiteSpace(controlsBlock))
// Controls schema/meanings are fat; Exact already carries current values. Only author/full dump.
if (includeAllShelves && onlyShelves is null)
{
sb.AppendLine();
sb.AppendLine(controlsBlock);
}
string controlsBlock = RenderControlsBlock(id);
if (!string.IsNullOrWhiteSpace(controlsBlock))
{
sb.AppendLine();
sb.AppendLine(controlsBlock);
}
string extra = parts["extra"]?.ToString() ?? "";
if (!string.IsNullOrWhiteSpace(extra))
{
sb.AppendLine();
sb.AppendLine(extra.Trim());
string extra = parts["extra"]?.ToString() ?? "";
if (!string.IsNullOrWhiteSpace(extra))
{
sb.AppendLine();
sb.AppendLine(extra.Trim());
}
}
return sb.ToString().TrimEnd();
}
/// <summary>Markdown for lore shelves not in always-on (persona_read hop).</summary>
public string RenderPersonaReadBlock(string personaId, IEnumerable<string> shelfNames)
{
string id = SafeId(personaId) ?? "neutral";
HashSet<string> always = IdentityAlwaysShelves(id);
List<string> want = [];
if (shelfNames is not null)
{
foreach (string raw in shelfNames)
{
string name = SafeId(raw) ?? raw?.Trim();
if (!string.IsNullOrWhiteSpace(name) && !always.Contains(name) && !string.Equals(name, "persona", StringComparison.OrdinalIgnoreCase))
{
want.Add(name);
}
}
}
if (want.Count == 0)
{
JObject parts = LoadIdentityParts(id);
foreach (JProperty prop in parts.Properties())
{
if (prop.Name is "persona" or "extra")
{
continue;
}
if (always.Contains(prop.Name))
{
continue;
}
if (prop.Value is JObject shelf && shelf.Properties().Any())
{
want.Add(prop.Name);
}
}
string extra = parts["extra"]?.ToString() ?? "";
if (!string.IsNullOrWhiteSpace(extra))
{
// RenderIdentityBlock with onlyShelves skips extra; append manually below if needed.
}
}
if (want.Count == 0)
{
JObject parts = LoadIdentityParts(id);
string extraOnly = parts["extra"]?.ToString() ?? "";
if (string.IsNullOrWhiteSpace(extraOnly))
{
return "";
}
return $"## Persona lore: {id}\n\n{extraOnly.Trim()}";
}
string body = RenderIdentityBlock(id, includeAllShelves: false, onlyShelves: want);
JObject all = LoadIdentityParts(id);
string extraMd = all["extra"]?.ToString() ?? "";
if (!string.IsNullOrWhiteSpace(extraMd) && (shelfNames is null || !shelfNames.Any() || shelfNames.Any(s => string.Equals(s, "extra", StringComparison.OrdinalIgnoreCase))))
{
body = string.IsNullOrWhiteSpace(body) ? extraMd.Trim() : body + "\n\n" + extraMd.Trim();
}
return body;
}
static IEnumerable<string> PersonaIdsUnder(string root)
{
string dir = Path.Combine(root ?? "", "personas");