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:
+259
-11
@@ -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/<fileName> (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/<id>. 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");
|
||||
|
||||
Reference in New Issue
Block a user