Ship persona shelves, Exact controls, and overlay author pipeline.

Add Leonid as a shelf-based example with preference_bias slider; support /persona new clone-to-overlay and UI-only delete.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-22 01:48:02 +03:00
co-authored by Cursor
parent ac8e30c7c8
commit cf89348f85
26 changed files with 1311 additions and 102 deletions
+448 -86
View File
@@ -265,6 +265,13 @@ public sealed class AssistentConfig
Scan(_bundledRoot, "bundled");
Scan(_overlayRoot, "overlay");
// Normalize source: overlay-only vs bundled vs both.
foreach (string id in byId.Keys.ToList())
{
var cur = byId[id];
byId[id] = (cur.title, cur.accent, PersonaSource(id));
}
// Legacy personas.json titles
string overlayJson = Path.Combine(_overlayRoot, "personas.json");
JObject legacy = TryReadJson(overlayJson);
@@ -283,7 +290,7 @@ public sealed class AssistentConfig
}
if (byId.TryGetValue(id, out var cur))
{
byId[id] = (po["title"]?.ToString() ?? cur.title, cur.accent, "overlay+bundled");
byId[id] = (po["title"]?.ToString() ?? cur.title, cur.accent, cur.source);
}
else
{
@@ -316,7 +323,7 @@ public sealed class AssistentConfig
return def;
}
/// <summary>Exact KV for the system prompt: params tables only (facts stay short / in RAG).</summary>
/// <summary>Exact KV for the system prompt: params tables + persona controls (facts stay short / in RAG).</summary>
public JObject LoadExactForPrompt(string personaId)
{
JObject full = LoadExact(personaId);
@@ -325,7 +332,7 @@ public sealed class AssistentConfig
return full ?? new JObject();
}
JObject slim = new();
foreach (string key in new[] { "generation", "profiles", "aspect_table" })
foreach (string key in new[] { "generation", "profiles", "aspect_table", "controls" })
{
if (full[key] is not null)
{
@@ -356,6 +363,351 @@ public sealed class AssistentConfig
return slim;
}
static readonly HashSet<string> ReservedConfigFiles = new(StringComparer.OrdinalIgnoreCase)
{
"exact.json", "controls.json", "skills.json", "ui.json", "assistant.json",
};
static readonly HashSet<string> ReservedConfigDirs = new(StringComparer.OrdinalIgnoreCase)
{
"memory-seed", "packs", "skills", "core", "models",
};
/// <summary>Identity shelf filenames (*.json) discovered under layer roots, excluding reserved config.</summary>
public List<string> DiscoverIdentityShelfFiles(string personaId)
{
HashSet<string> names = new(StringComparer.OrdinalIgnoreCase);
foreach (string root in LayerRoots(personaId))
{
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
{
continue;
}
foreach (string file in Directory.GetFiles(root, "*.json"))
{
string name = Path.GetFileName(file);
if (ReservedConfigFiles.Contains(name))
{
continue;
}
names.Add(name);
}
}
// Stable order: persona first, then alpha.
return names.OrderBy(n => string.Equals(n, "persona.json", StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.ThenBy(n => n, StringComparer.OrdinalIgnoreCase)
.ToList();
}
public JObject LoadControlsSchema(string personaId) => MergeJsonLayers("controls.json", LayerRoots(personaId));
public JObject LoadControlValues(string personaId)
{
JObject exact = LoadExact(personaId);
return exact["controls"] as JObject ?? new JObject();
}
/// <summary>Clamp and merge control values into overlay exact.json (controls key only).</summary>
public JObject SaveControlValues(string personaId, JObject values)
{
string id = SafeId(personaId) ?? "neutral";
JObject schema = LoadControlsSchema(id);
JObject clamped = ClampControls(schema, values ?? new JObject());
string dir = Path.Combine(_overlayRoot, "personas", id);
Directory.CreateDirectory(dir);
string path = Path.Combine(dir, "exact.json");
JObject existing = TryReadJson(path) ?? new JObject();
existing["controls"] = clamped;
File.WriteAllText(path, existing.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
return LoadControlValues(id);
}
public static JObject ClampControls(JObject schema, JObject values)
{
JObject result = new();
if (schema is null || values is null)
{
return result;
}
foreach (JProperty prop in schema.Properties())
{
if (values[prop.Name] is null)
{
continue;
}
JObject def = prop.Value as JObject;
if (def is null)
{
continue;
}
string type = def["type"]?.ToString() ?? "slider";
if (!string.Equals(type, "slider", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!double.TryParse(values[prop.Name]?.ToString(), System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out double v))
{
continue;
}
double min = def["min"]?.Value<double?>() ?? -1;
double max = def["max"]?.Value<double?>() ?? 1;
if (v < min)
{
v = min;
}
if (v > max)
{
v = max;
}
result[prop.Name] = Math.Round(v, 4);
}
return result;
}
public string RenderControlsBlock(string personaId)
{
string id = SafeId(personaId) ?? "neutral";
JObject schema = LoadControlsSchema(id);
if (schema is null || !schema.Properties().Any())
{
return "";
}
JObject values = LoadControlValues(id);
StringBuilder sb = new();
sb.AppendLine("### Controls (Exact — current values; user/UI/model may change)");
foreach (JProperty prop in schema.Properties())
{
if (prop.Value is not JObject def)
{
continue;
}
string type = def["type"]?.ToString() ?? "slider";
if (!string.Equals(type, "slider", StringComparison.OrdinalIgnoreCase))
{
continue;
}
double min = def["min"]?.Value<double?>() ?? -1;
double max = def["max"]?.Value<double?>() ?? 1;
double defVal = def["default"]?.Value<double?>() ?? 0;
double cur = values[prop.Name]?.Value<double?>() ?? defVal;
string label = def["label"]?.ToString() ?? prop.Name;
string hint = def["hint"]?.ToString() ?? "";
sb.AppendLine($"- **{prop.Name}** ({label}): {cur.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)} (range {min}…{max})");
if (!string.IsNullOrWhiteSpace(hint))
{
sb.AppendLine($" - {hint}");
}
if (def["meaning"] is JObject meaning)
{
foreach (JProperty m in meaning.Properties())
{
sb.AppendLine($" - {m.Name}: {m.Value}");
}
}
}
return sb.ToString().TrimEnd();
}
public bool IsBundledPersona(string personaId)
{
string id = SafeId(personaId);
if (id is null)
{
return false;
}
string path = Path.Combine(_bundledRoot, "personas", id, "persona.json");
return File.Exists(path);
}
public bool IsOverlayPersona(string personaId)
{
string id = SafeId(personaId);
if (id is null)
{
return false;
}
string dir = Path.Combine(_overlayRoot, "personas", id);
return Directory.Exists(dir)
&& (File.Exists(Path.Combine(dir, "persona.json")) || File.Exists(Path.Combine(dir, "extra.md")));
}
public string PersonaSource(string personaId)
{
if (IsOverlayPersona(personaId) && !IsBundledPersona(personaId))
{
return "overlay";
}
if (IsOverlayPersona(personaId) && IsBundledPersona(personaId))
{
return "overlay+bundled";
}
if (IsBundledPersona(personaId))
{
return "bundled";
}
return "unknown";
}
static readonly HashSet<string> WritableShelfNames = new(StringComparer.OrdinalIgnoreCase)
{
"persona.json", "bio.json", "voice.json", "humor.json", "craft.json",
"appearance.json", "outfits.json", "roleplay.json", "likes.json", "dislikes.json",
"rules.json", "controls.json", "exact.json", "extra.md",
};
public static bool IsWritableShelfName(string fileName)
{
string name = Path.GetFileName(fileName ?? "");
if (string.IsNullOrWhiteSpace(name) || name.Contains("..") || name.Contains('/') || name.Contains('\\'))
{
return false;
}
if (WritableShelfNames.Contains(name))
{
return true;
}
// Allow extra identity *.json shelves (not reserved).
return name.EndsWith(".json", StringComparison.OrdinalIgnoreCase)
&& !ReservedConfigFiles.Contains(name)
&& !string.Equals(name, "skills.json", StringComparison.OrdinalIgnoreCase);
}
/// <summary>Materialize merged identity shelves (+ controls/exact.controls) into overlay personas/toId.</summary>
public JObject ClonePersonaToOverlay(string fromId, string toId, string title, bool overwrite = false)
{
string from = SafeId(fromId) ?? throw new ArgumentException("invalid from id");
string to = SafeId(toId) ?? throw new ArgumentException("invalid to id");
if (IsBundledPersona(to))
{
throw new InvalidOperationException($"cannot overwrite bundled persona '{to}'");
}
string dest = Path.Combine(_overlayRoot, "personas", to);
if (Directory.Exists(dest) && Directory.EnumerateFileSystemEntries(dest).Any() && !overwrite)
{
throw new InvalidOperationException($"overlay persona '{to}' already exists");
}
Directory.CreateDirectory(dest);
JObject shelves = LoadIdentityParts(from);
foreach (JProperty prop in shelves.Properties())
{
if (prop.Name == "extra")
{
string extra = prop.Value?.ToString() ?? "";
if (!string.IsNullOrWhiteSpace(extra))
{
File.WriteAllText(Path.Combine(dest, "extra.md"), extra.TrimEnd() + "\n", Encoding.UTF8);
}
continue;
}
if (prop.Value is JObject jo && jo.Properties().Any())
{
File.WriteAllText(Path.Combine(dest, prop.Name + ".json"),
jo.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
}
}
JObject controls = LoadControlsSchema(from);
if (controls.Properties().Any())
{
File.WriteAllText(Path.Combine(dest, "controls.json"),
controls.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
}
JObject values = LoadControlValues(from);
if (values.Properties().Any())
{
File.WriteAllText(Path.Combine(dest, "exact.json"),
new JObject { ["controls"] = values }.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
}
// Ensure persona.json title.
string personaPath = Path.Combine(dest, "persona.json");
JObject meta = TryReadJson(personaPath) ?? new JObject();
if (!string.IsNullOrWhiteSpace(title))
{
meta["title"] = title.Trim();
}
meta.Remove("extends");
File.WriteAllText(personaPath, meta.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
return new JObject { ["id"] = to, ["title"] = meta["title"]?.ToString() ?? to, ["source"] = "overlay" };
}
/// <summary>Sparse DeepMerge shelves into overlay personas/id. Overlay-only ids (or existing overlay).</summary>
public JObject SavePersonaShelves(string personaId, JObject shelves, bool allowBundledShadow = false)
{
string id = SafeId(personaId) ?? throw new ArgumentException("invalid persona id");
if (IsBundledPersona(id) && !allowBundledShadow && !IsOverlayPersona(id))
{
// v1: do not shadow-write bundled; require clone to a new overlay id.
throw new InvalidOperationException($"cannot write bundled persona '{id}' — clone to a new overlay id");
}
if (IsBundledPersona(id) && !IsOverlayPersona(id))
{
throw new InvalidOperationException($"cannot write bundled persona '{id}' — clone to a new overlay id");
}
string dest = Path.Combine(_overlayRoot, "personas", id);
Directory.CreateDirectory(dest);
if (shelves is null)
{
return LoadIdentityParts(id);
}
foreach (JProperty prop in shelves.Properties())
{
string fileName = prop.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase)
|| prop.Name.EndsWith(".md", StringComparison.OrdinalIgnoreCase)
? Path.GetFileName(prop.Name)
: prop.Name + ".json";
if (!IsWritableShelfName(fileName))
{
continue;
}
string path = Path.Combine(dest, fileName);
if (fileName.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
{
string text = prop.Value?.Type == JTokenType.String ? prop.Value.ToString() : prop.Value?.ToString() ?? "";
if (!string.IsNullOrWhiteSpace(text))
{
File.WriteAllText(path, text.TrimEnd() + "\n", Encoding.UTF8);
}
continue;
}
JObject incoming = prop.Value as JObject;
if (incoming is null)
{
continue;
}
JObject existing = TryReadJson(path) ?? new JObject();
JObject merged = DeepMerge(existing, incoming);
File.WriteAllText(path, merged.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
}
return LoadIdentityParts(id);
}
public bool DeleteOverlayPersona(string personaId)
{
string id = SafeId(personaId);
if (id is null)
{
return false;
}
if (IsBundledPersona(id) && !IsOverlayPersona(id))
{
throw new InvalidOperationException($"cannot delete bundled persona '{id}'");
}
// Only delete overlay folder; never touch bundled.
string dest = Path.Combine(_overlayRoot, "personas", id);
if (!Directory.Exists(dest))
{
return false;
}
if (IsBundledPersona(id))
{
// Overlay shadow of a bundled id — remove overlay only (reverts to bundled).
Directory.Delete(dest, true);
return true;
}
Directory.Delete(dest, true);
return true;
}
public JObject LoadAssistant(string personaId) => MergeJsonLayers("assistant.json", LayerRoots(personaId));
public JObject LoadUi(string personaId) => MergeJsonLayers("ui.json", LayerRoots(personaId));
@@ -492,16 +844,25 @@ public sealed class AssistentConfig
public JObject LoadIdentityParts(string personaId)
{
var roots = LayerRoots(personaId).ToList();
JObject persona = MergeJsonLayers("persona.json", roots);
JObject voice = MergeJsonLayers("voice.json", roots);
JObject likes = MergeJsonLayers("likes.json", roots);
JObject dislikes = MergeJsonLayers("dislikes.json", roots);
JObject rules = MergeJsonLayers("rules.json", roots);
string id = SafeId(personaId) ?? "neutral";
JObject shelves = new();
foreach (string fileName in DiscoverIdentityShelfFiles(id))
{
string key = Path.GetFileNameWithoutExtension(fileName);
JObject merged = MergeJsonLayers(fileName, roots);
if (merged is not null && merged.Properties().Any())
{
shelves[key] = merged;
}
}
// Always expose persona key (may be empty template).
if (shelves["persona"] is null)
{
shelves["persona"] = MergeJsonLayers("persona.json", roots);
}
string extra = MergeTextLayers("extra.md", roots);
// Legacy personas.json: only when no overlay persona folder exists for this id
// (gpu-rent now seeds personas/<id>/extra.md instead of dumping personas.json).
string id = SafeId(personaId) ?? "neutral";
// Legacy personas.json: only when no overlay persona folder exists for this id.
string overlayPersonaDir = Path.Combine(_overlayRoot, "personas", id);
bool hasOverlayFolder = Directory.Exists(overlayPersonaDir)
&& (File.Exists(Path.Combine(overlayPersonaDir, "persona.json"))
@@ -516,11 +877,13 @@ public sealed class AssistentConfig
{
if (t is JObject po && string.Equals(SafeId(po["id"]?.ToString()), id, StringComparison.OrdinalIgnoreCase))
{
JObject persona = shelves["persona"] as JObject ?? new JObject();
string title = po["title"]?.ToString();
if (!string.IsNullOrWhiteSpace(title))
{
persona["title"] = title;
}
shelves["persona"] = persona;
string prompt = po["prompt"]?.ToString();
if (!string.IsNullOrWhiteSpace(prompt))
{
@@ -532,44 +895,71 @@ public sealed class AssistentConfig
}
}
return new JObject
{
["persona"] = persona,
["voice"] = voice,
["likes"] = likes,
["dislikes"] = dislikes,
["rules"] = rules,
["extra"] = extra ?? "",
};
shelves["extra"] = extra ?? "";
return shelves;
}
static string FormatCategoryMap(JObject obj)
static void AppendTokenMarkdown(StringBuilder sb, JToken token, int depth)
{
if (obj is null || !obj.Properties().Any())
if (token is null || token.Type == JTokenType.Null)
{
return "";
return;
}
List<string> parts = [];
foreach (JProperty prop in obj.Properties())
string indent = new string(' ', Math.Max(0, depth) * 2);
if (token is JArray arr)
{
if (prop.Value is JArray arr)
foreach (JToken item in arr)
{
string joined = string.Join(", ", arr.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)));
if (!string.IsNullOrWhiteSpace(joined))
if (item is JObject || item is JArray)
{
parts.Add($"{prop.Name}: {joined}");
sb.AppendLine($"{indent}-");
AppendTokenMarkdown(sb, item, depth + 1);
}
else
{
string s = item?.ToString();
if (!string.IsNullOrWhiteSpace(s))
{
sb.AppendLine($"{indent}- {s}");
}
}
}
else if (prop.Value?.Type == JTokenType.String)
return;
}
if (token is JObject obj)
{
foreach (JProperty prop in obj.Properties())
{
string s = prop.Value.ToString();
if (!string.IsNullOrWhiteSpace(s))
if (prop.Value is JArray or JObject)
{
parts.Add($"{prop.Name}: {s}");
sb.AppendLine($"{indent}- **{prop.Name}:**");
AppendTokenMarkdown(sb, prop.Value, depth + 1);
}
else
{
string s = prop.Value?.ToString();
if (!string.IsNullOrWhiteSpace(s))
{
sb.AppendLine($"{indent}- **{prop.Name}:** {s}");
}
}
}
return;
}
return string.Join("; ", parts);
string scalar = token.ToString();
if (!string.IsNullOrWhiteSpace(scalar))
{
sb.AppendLine($"{indent}- {scalar}");
}
}
static string TitleCaseShelf(string key)
{
if (string.IsNullOrWhiteSpace(key))
{
return key;
}
return char.ToUpperInvariant(key[0]) + key[1..];
}
public string RenderIdentityBlock(string personaId)
@@ -577,74 +967,41 @@ public sealed class AssistentConfig
string id = SafeId(personaId) ?? "neutral";
JObject parts = LoadIdentityParts(id);
JObject persona = parts["persona"] as JObject ?? new JObject();
JObject voice = parts["voice"] as JObject ?? new JObject();
JObject likes = parts["likes"] as JObject ?? new JObject();
JObject dislikes = parts["dislikes"] as JObject ?? new JObject();
JObject rules = parts["rules"] as JObject ?? new JObject();
string extra = parts["extra"]?.ToString() ?? "";
string title = persona["title"]?.ToString() ?? id;
string tagline = persona["tagline"]?.ToString();
StringBuilder sb = new();
sb.AppendLine($"## Persona: {id} — {title}");
List<string> voiceBits = [];
if (voice["verbosity"] != null)
if (!string.IsNullOrWhiteSpace(tagline))
{
voiceBits.Add(voice["verbosity"].ToString());
}
if (voice["tone"] is JArray tones)
{
voiceBits.AddRange(tones.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)));
}
if (voice["humor"] != null && voice["humor"].ToString() != "none")
{
voiceBits.Add($"humor:{voice["humor"]}");
}
if (voice["nsfw"] != null)
{
voiceBits.Add($"NSFW {voice["nsfw"]}");
}
if (voice["language"] != null)
{
voiceBits.Add(voice["language"].ToString());
}
if (voiceBits.Count > 0)
{
sb.AppendLine("Voice: " + string.Join(", ", voiceBits));
sb.AppendLine($"*{tagline}*");
}
string prefers = FormatCategoryMap(likes);
if (!string.IsNullOrWhiteSpace(prefers))
foreach (JProperty prop in parts.Properties())
{
sb.AppendLine("Prefers: " + prefers);
}
string avoids = FormatCategoryMap(dislikes);
if (!string.IsNullOrWhiteSpace(avoids))
{
sb.AppendLine("Avoids: " + avoids);
}
List<string> ruleBits = [];
if (rules["always"] is JArray always)
{
foreach (string s in always.Select(t => t?.ToString()).Where(x => !string.IsNullOrWhiteSpace(x)))
if (prop.Name is "persona" or "extra")
{
ruleBits.Add("always " + s);
continue;
}
}
if (rules["never"] is JArray never)
{
foreach (string s in never.Select(t => t?.ToString()).Where(x => !string.IsNullOrWhiteSpace(x)))
if (prop.Value is not JObject shelf || !shelf.Properties().Any())
{
ruleBits.Add("never " + s);
continue;
}
sb.AppendLine();
sb.AppendLine($"### {TitleCaseShelf(prop.Name)}");
AppendTokenMarkdown(sb, shelf, 0);
}
if (ruleBits.Count > 0)
string controlsBlock = RenderControlsBlock(id);
if (!string.IsNullOrWhiteSpace(controlsBlock))
{
sb.AppendLine("Rules: " + string.Join("; ", ruleBits));
sb.AppendLine();
sb.AppendLine(controlsBlock);
}
string extra = parts["extra"]?.ToString() ?? "";
if (!string.IsNullOrWhiteSpace(extra))
{
sb.AppendLine();
sb.AppendLine(extra.Trim());
}
return sb.ToString().TrimEnd();
@@ -791,6 +1148,8 @@ public sealed class AssistentConfig
var skills = ListSkills(id);
var personas = ListPersonaCatalog();
JObject identity = LoadIdentityParts(id);
JObject controlsSchema = LoadControlsSchema(id);
JObject controlValues = LoadControlValues(id);
return new JObject
{
["success"] = true,
@@ -800,6 +1159,9 @@ public sealed class AssistentConfig
["ui"] = ui,
["model"] = model,
["exact"] = exact,
["controls"] = controlsSchema,
["control_values"] = controlValues,
["persona_source"] = PersonaSource(id),
["packs"] = new JArray(packs.Select(p => new JObject
{
["id"] = p.id,