using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using FreneticUtilities.FreneticExtensions;
using Newtonsoft.Json.Linq;
using SwarmUI.Utils;
namespace Mrleo1nid.SwarmAssistent;
/// Loads Config/_base + personas/<id> sparse presets with disk overlay merge.
public sealed class AssistentConfig
{
readonly string _bundledRoot;
readonly string _overlayRoot;
readonly object _lock = new();
public AssistentConfig(string extensionFilePath, string dataRoot)
{
_bundledRoot = Path.Combine(extensionFilePath ?? "", "Config");
_overlayRoot = Path.Combine(dataRoot ?? "", "Assistent");
}
public string BundledRoot => _bundledRoot;
public string OverlayRoot => _overlayRoot;
public static string SafeId(string id)
{
string s = (id ?? "").Replace('\\', '/').AfterLast('/').Replace("..", "").Trim();
if (string.IsNullOrWhiteSpace(s) || !Regex.IsMatch(s, @"^[A-Za-z0-9][A-Za-z0-9_\-]{0,63}$"))
{
return null;
}
return s;
}
static string SafeRel(string relative)
{
if (string.IsNullOrWhiteSpace(relative))
{
return null;
}
string norm = relative.Replace('\\', '/').TrimStart('/');
if (norm.Contains("..", StringComparison.Ordinal) || Path.IsPathRooted(relative))
{
return null;
}
return norm.Replace('/', Path.DirectorySeparatorChar);
}
public string ResolveUnder(string root, string relative)
{
string rel = SafeRel(relative);
if (rel is null || string.IsNullOrWhiteSpace(root))
{
return null;
}
string full = Path.GetFullPath(Path.Combine(root, rel));
string rootFull = Path.GetFullPath(root);
if (!full.StartsWith(rootFull.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(full, rootFull, StringComparison.OrdinalIgnoreCase))
{
return null;
}
return full;
}
public JObject DeepMerge(JObject bottom, JObject top)
{
if (bottom is null)
{
return top is null ? new JObject() : (JObject)top.DeepClone();
}
if (top is null)
{
return (JObject)bottom.DeepClone();
}
JObject result = (JObject)bottom.DeepClone();
foreach (JProperty prop in top.Properties())
{
if (prop.Value is JObject topObj && result[prop.Name] is JObject botObj)
{
result[prop.Name] = DeepMerge(botObj, topObj);
}
else if (prop.Value is JArray || prop.Value is null || prop.Value.Type == JTokenType.Null)
{
// Arrays replace entirely when the top file provides them.
if (prop.Value is not null && prop.Value.Type != JTokenType.Null)
{
result[prop.Name] = prop.Value.DeepClone();
}
}
else if (prop.Value.Type == JTokenType.String && string.IsNullOrWhiteSpace(prop.Value.ToString()))
{
// Empty string does not clobber (personas.json empty prompt rule).
continue;
}
else
{
result[prop.Name] = prop.Value.DeepClone();
}
}
return result;
}
public JObject TryReadJson(string path)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return null;
}
try
{
return JObject.Parse(File.ReadAllText(path, Encoding.UTF8));
}
catch (Exception ex)
{
Logs.Debug($"AssistentConfig json {path}: {ex.Message}");
return null;
}
}
public JArray TryReadJsonArray(string path)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return null;
}
try
{
return JArray.Parse(File.ReadAllText(path, Encoding.UTF8));
}
catch (Exception ex)
{
Logs.Debug($"AssistentConfig json-array {path}: {ex.Message}");
return null;
}
}
public string TryReadText(string path)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return null;
}
try
{
string text = File.ReadAllText(path, Encoding.UTF8);
return string.IsNullOrWhiteSpace(text) ? null : text;
}
catch (Exception ex)
{
Logs.Debug($"AssistentConfig text {path}: {ex.Message}");
return null;
}
}
/// Merge layered copies of the same relative path: bundled base → persona chain → disk base → disk persona.
public JObject MergeJsonLayers(string relative, IEnumerable roots)
{
JObject acc = null;
foreach (string root in roots)
{
string path = ResolveUnder(root, relative);
JObject next = TryReadJson(path);
if (next is null)
{
continue;
}
acc = DeepMerge(acc, next);
}
return acc ?? new JObject();
}
public string MergeTextLayers(string relative, IEnumerable roots)
{
string last = null;
foreach (string root in roots)
{
string path = ResolveUnder(root, relative);
string text = TryReadText(path);
if (text is not null)
{
last = text;
}
}
return last;
}
/// Ancestor-first chain ending with the current persona (for overlay merge and vector retrieve).
public List PersonaExtendsChain(string personaId)
{
List chain = [];
HashSet seen = new(StringComparer.OrdinalIgnoreCase);
string cur = SafeId(personaId) ?? "neutral";
for (int i = 0; i < 8 && !string.IsNullOrWhiteSpace(cur); i++)
{
if (!seen.Add(cur))
{
break;
}
chain.Insert(0, cur);
JObject meta = TryReadJson(ResolveUnder(Path.Combine(_bundledRoot, "personas", cur), "persona.json"))
?? TryReadJson(ResolveUnder(Path.Combine(_overlayRoot, "personas", cur), "persona.json"));
string parent = SafeId(meta?["extends"]?.ToString());
if (string.IsNullOrWhiteSpace(parent) || string.Equals(parent, cur, StringComparison.OrdinalIgnoreCase))
{
break;
}
cur = parent;
}
return chain;
}
public IEnumerable LayerRoots(string personaId)
{
yield return Path.Combine(_bundledRoot, "_base");
foreach (string id in PersonaExtendsChain(personaId))
{
yield return Path.Combine(_bundledRoot, "personas", id);
}
yield return Path.Combine(_overlayRoot, "_base");
foreach (string id in PersonaExtendsChain(personaId))
{
yield return Path.Combine(_overlayRoot, "personas", id);
}
}
public List<(string id, string title, string accent, string source)> ListPersonaCatalog()
{
Dictionary byId = new(StringComparer.OrdinalIgnoreCase);
void Scan(string root, string source)
{
string dir = Path.Combine(root, "personas");
if (!Directory.Exists(dir))
{
return;
}
foreach (string folder in Directory.GetDirectories(dir))
{
string id = SafeId(Path.GetFileName(folder));
if (id is null)
{
continue;
}
JObject meta = TryReadJson(Path.Combine(folder, "persona.json"));
if (meta is null)
{
continue;
}
if (meta["enabled"]?.Value() == false)
{
byId.Remove(id);
continue;
}
byId[id] = (
meta["title"]?.ToString() ?? id,
meta["accent"]?.ToString() ?? "#8b949e",
source
);
}
}
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);
if (legacy?["personas"] is JArray arr)
{
foreach (JToken t in arr)
{
if (t is not JObject po)
{
continue;
}
string id = SafeId(po["id"]?.ToString());
if (id is null)
{
continue;
}
if (byId.TryGetValue(id, out var cur))
{
byId[id] = (po["title"]?.ToString() ?? cur.title, cur.accent, cur.source);
}
else
{
byId[id] = (po["title"]?.ToString() ?? id, "#8b949e", "legacy");
}
}
}
return byId.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
.Select(kv => (kv.Key, kv.Value.title, kv.Value.accent, kv.Value.source))
.ToList();
}
public string DefaultPersonaId()
{
JObject assistant = MergeJsonLayers("assistant.json", LayerRoots("neutral"));
string def = SafeId(assistant["default_persona"]?.ToString()) ?? "neutral";
string overlayJson = Path.Combine(_overlayRoot, "personas.json");
JObject legacy = TryReadJson(overlayJson);
string fromLegacy = SafeId(legacy?["default"]?.ToString());
if (fromLegacy is not null)
{
def = fromLegacy;
}
var catalog = ListPersonaCatalog();
if (catalog.All(p => !string.Equals(p.id, def, StringComparison.OrdinalIgnoreCase)) && catalog.Count > 0)
{
def = catalog[0].id;
}
return def;
}
/// Exact KV for the system prompt: params tables + persona controls (facts stay short / in RAG).
public JObject LoadExactForPrompt(string personaId)
{
JObject full = LoadExact(personaId);
if (full is null || full.Count == 0)
{
return full ?? new JObject();
}
JObject slim = new();
foreach (string key in new[] { "generation", "profiles", "aspect_table", "controls" })
{
if (full[key] is not null)
{
slim[key] = full[key].DeepClone();
}
}
if (full["facts"] is JObject facts)
{
// Keep short pointers only — long prose bloats 7B context and kills JSON discipline.
JObject shortFacts = new();
foreach (JProperty prop in facts.Properties())
{
string text = prop.Value?.ToString() ?? "";
if (text.Length <= 160)
{
shortFacts[prop.Name] = text;
}
else
{
shortFacts[prop.Name] = text.Substring(0, 157) + "…";
}
}
if (shortFacts.Count > 0)
{
slim["facts"] = shortFacts;
}
}
return slim;
}
static readonly HashSet ReservedConfigFiles = new(StringComparer.OrdinalIgnoreCase)
{
"exact.json", "controls.json", "skills.json", "ui.json", "assistant.json",
};
static readonly HashSet ReservedConfigDirs = new(StringComparer.OrdinalIgnoreCase)
{
"memory-seed", "packs", "skills", "core", "models",
};
/// Identity shelf filenames (*.json) discovered under layer roots, excluding reserved config.
public List DiscoverIdentityShelfFiles(string personaId)
{
HashSet 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();
}
/// Clamp and merge control values into overlay exact.json (controls key only).
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() ?? -1;
double max = def["max"]?.Value() ?? 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() ?? -1;
double max = def["max"]?.Value() ?? 1;
double defVal = def["default"]?.Value() ?? 0;
double cur = values[prop.Name]?.Value() ?? 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 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);
}
/// Materialize merged identity shelves (+ controls/exact.controls) into overlay personas/toId.
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" };
}
/// Sparse DeepMerge shelves into overlay personas/id. Overlay-only ids (or existing overlay).
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));
/// Exact (KV) memory: bundled base → persona overlays → disk overlays. Persona keys overwrite base.
public JObject LoadExact(string personaId) => MergeJsonLayers("exact.json", LayerRoots(personaId));
/// DeepMerge sparse keys into Assistent/_base/<fileName> (disk overlay only).
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;
}
/// Export a shareable persona pack (merged shelves + controls + personal memory-seed).
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,
};
}
/// Import pack into overlay personas/<id>. Never overwrites bundled files.
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);
string arch = assistant["gate"]?["architecture"]?.ToString() ?? "krea2";
string safe = SafeId(arch) ?? "krea2";
return MergeJsonLayers(Path.Combine("models", $"{safe}.json"), LayerRoots(personaId));
}
public string LoadCorePrompt(string personaId)
{
JObject meta = MergeJsonLayers(Path.Combine("core", "core.json"), LayerRoots(personaId));
string file = meta["prompt_file"]?.ToString() ?? "core.md";
return MergeTextLayers(Path.Combine("core", file), LayerRoots(personaId)) ?? "";
}
public List<(string id, string title, int order, string[] aliases, bool enabled)> ListPacks(string personaId)
{
Dictionary byId = new(StringComparer.OrdinalIgnoreCase);
foreach (string root in LayerRoots(personaId))
{
string dir = Path.Combine(root, "packs");
if (!Directory.Exists(dir))
{
continue;
}
foreach (string file in Directory.GetFiles(dir, "*.json"))
{
JObject meta = TryReadJson(file);
string id = SafeId(meta?["id"]?.ToString() ?? Path.GetFileNameWithoutExtension(file));
if (id is null || meta is null)
{
continue;
}
bool enabled = meta["enabled"]?.Value() != false;
string[] aliases = (meta["aliases"] as JArray)?.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)).ToArray() ?? [];
byId[id] = (
meta["title"]?.ToString() ?? id,
meta["order"]?.Value() ?? 100,
aliases,
enabled
);
}
}
return byId.Where(kv => kv.Value.enabled)
.OrderBy(kv => kv.Value.order).ThenBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
.Select(kv => (kv.Key, kv.Value.title, kv.Value.order, kv.Value.aliases, kv.Value.enabled))
.ToList();
}
public string LoadPackPrompt(string personaId, string packId)
{
string id = SafeId(packId);
if (id is null)
{
return null;
}
JObject meta = MergeJsonLayers(Path.Combine("packs", $"{id}.json"), LayerRoots(personaId));
if (meta["enabled"]?.Value() == false)
{
return null;
}
string file = meta["prompt_file"]?.ToString() ?? $"{id}.md";
return MergeTextLayers(Path.Combine("packs", file), LayerRoots(personaId));
}
public List<(string id, string title, bool defaultOn, bool enabled)> ListSkills(string personaId)
{
Dictionary byId = new(StringComparer.OrdinalIgnoreCase);
foreach (string root in LayerRoots(personaId))
{
string dir = Path.Combine(root, "skills");
if (!Directory.Exists(dir))
{
continue;
}
foreach (string file in Directory.GetFiles(dir, "*.json"))
{
JObject meta = TryReadJson(file);
string id = SafeId(meta?["id"]?.ToString() ?? Path.GetFileNameWithoutExtension(file));
if (id is null || meta is null)
{
continue;
}
byId[id] = (
meta["title"]?.ToString() ?? id,
meta["default"]?.Value() ?? false,
meta["enabled"]?.Value() != false
);
}
}
JObject skillsOverride = MergeJsonLayers("skills.json", LayerRoots(personaId));
foreach (JProperty prop in skillsOverride.Properties())
{
string id = SafeId(prop.Name);
if (id is null || !byId.ContainsKey(id))
{
continue;
}
if (prop.Value.Type == JTokenType.Boolean)
{
var cur = byId[id];
byId[id] = (cur.title, prop.Value.Value(), cur.enabled);
}
}
return byId.Where(kv => kv.Value.enabled)
.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
.Select(kv => (kv.Key, kv.Value.title, kv.Value.defaultOn, kv.Value.enabled))
.ToList();
}
public string LoadSkillPrompt(string personaId, string skillId)
{
string id = SafeId(skillId);
if (id is null)
{
return null;
}
JObject meta = MergeJsonLayers(Path.Combine("skills", $"{id}.json"), LayerRoots(personaId));
if (meta["enabled"]?.Value() == false)
{
return null;
}
string file = meta["prompt_file"]?.ToString() ?? $"{id}.md";
return MergeTextLayers(Path.Combine("skills", file), LayerRoots(personaId));
}
public JObject LoadIdentityParts(string personaId)
{
var roots = LayerRoots(personaId).ToList();
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.
string overlayPersonaDir = Path.Combine(_overlayRoot, "personas", id);
bool hasOverlayFolder = Directory.Exists(overlayPersonaDir)
&& (File.Exists(Path.Combine(overlayPersonaDir, "persona.json"))
|| File.Exists(Path.Combine(overlayPersonaDir, "extra.md")));
if (!hasOverlayFolder)
{
string overlayJson = Path.Combine(_overlayRoot, "personas.json");
JObject legacy = TryReadJson(overlayJson);
if (legacy?["personas"] is JArray arr)
{
foreach (JToken t in arr)
{
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))
{
extra = string.IsNullOrWhiteSpace(extra) ? prompt : extra + "\n\n" + prompt;
}
break;
}
}
}
}
shelves["extra"] = extra ?? "";
return shelves;
}
static void AppendTokenMarkdown(StringBuilder sb, JToken token, int depth)
{
if (token is null || token.Type == JTokenType.Null)
{
return;
}
string indent = new string(' ', Math.Max(0, depth) * 2);
if (token is JArray arr)
{
foreach (JToken item in arr)
{
if (item is JObject || item is JArray)
{
sb.AppendLine($"{indent}-");
AppendTokenMarkdown(sb, item, depth + 1);
}
else
{
string s = item?.ToString();
if (!string.IsNullOrWhiteSpace(s))
{
sb.AppendLine($"{indent}- {s}");
}
}
}
return;
}
if (token is JObject obj)
{
foreach (JProperty prop in obj.Properties())
{
if (prop.Value is JArray or JObject)
{
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;
}
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..];
}
static readonly string[] DefaultIdentityAlwaysShelves =
["persona", "voice", "rules", "likes", "dislikes"];
/// Shelves always injected into the system prompt. Lore shelves load via persona_read hop.
public HashSet IdentityAlwaysShelves(string personaId)
{
HashSet 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;
}
/// Render always-on identity (voice/taste). Pass includeAllShelves for author pack / persona_read.
public string RenderIdentityBlock(string personaId, bool includeAllShelves = false, IEnumerable onlyShelves = null)
{
string id = SafeId(personaId) ?? "neutral";
JObject parts = LoadIdentityParts(id);
JObject persona = parts["persona"] as JObject ?? new JObject();
string title = persona["title"]?.ToString() ?? id;
string tagline = persona["tagline"]?.ToString();
StringBuilder sb = new();
sb.AppendLine($"## Persona: {id} — {title}");
if (!string.IsNullOrWhiteSpace(tagline))
{
sb.AppendLine($"*{tagline}*");
}
HashSet allow = null;
if (onlyShelves is not null)
{
allow = new HashSet(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;
}
sb.AppendLine();
sb.AppendLine($"### {TitleCaseShelf(prop.Name)}");
AppendTokenMarkdown(sb, shelf, 0);
}
// Controls schema/meanings are fat; Exact already carries current values. Only author/full dump.
if (includeAllShelves && onlyShelves is null)
{
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());
}
}
return sb.ToString().TrimEnd();
}
/// Markdown for lore shelves not in always-on (persona_read hop).
public string RenderPersonaReadBlock(string personaId, IEnumerable shelfNames)
{
string id = SafeId(personaId) ?? "neutral";
HashSet always = IdentityAlwaysShelves(id);
List 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 PersonaIdsUnder(string root)
{
string dir = Path.Combine(root ?? "", "personas");
if (!Directory.Exists(dir))
{
yield break;
}
foreach (string folder in Directory.GetDirectories(dir))
{
string id = SafeId(Path.GetFileName(folder));
if (id is not null)
{
yield return id;
}
}
}
/// Seed docs: shared from _base/memory-seed, personal from personas/<id>/memory-seed. Later files overwrite earlier same kind+key in the list; persona is stamped on each doc.
public List LoadMemorySeedDocs()
{
List docs = [];
void Scan(string dir, string persona)
{
if (string.IsNullOrWhiteSpace(dir) || !Directory.Exists(dir))
{
return;
}
string stamp = AssistentMemory.NormalizePersona(persona);
foreach (string file in Directory.GetFiles(dir, "*.json").OrderBy(f => f, StringComparer.OrdinalIgnoreCase))
{
try
{
string raw = File.ReadAllText(file, Encoding.UTF8);
JToken parsed = JToken.Parse(raw);
IEnumerable items = parsed is JArray arr
? arr.OfType()
: parsed is JObject single ? new[] { single } : [];
foreach (JObject jo in items)
{
JObject clone = (JObject)jo.DeepClone();
if (clone["persona"] is null || string.IsNullOrWhiteSpace(clone["persona"]?.ToString()))
{
clone["persona"] = stamp;
}
docs.Add(clone);
}
}
catch (Exception ex)
{
Logs.Debug($"AssistentConfig memory-seed {file}: {ex.Message}");
}
}
}
Scan(Path.Combine(_bundledRoot, "_base", "memory-seed"), AssistentMemory.SharedPersona);
Scan(Path.Combine(_overlayRoot, "_base", "memory-seed"), AssistentMemory.SharedPersona);
Scan(Path.Combine(_overlayRoot, "memory-seed"), AssistentMemory.SharedPersona);
foreach (string id in PersonaIdsUnder(_bundledRoot).Concat(PersonaIdsUnder(_overlayRoot)).Distinct(StringComparer.OrdinalIgnoreCase))
{
Scan(Path.Combine(_bundledRoot, "personas", id, "memory-seed"), id);
Scan(Path.Combine(_overlayRoot, "personas", id, "memory-seed"), id);
}
return docs;
}
public JObject LoadSettings()
{
return TryReadJson(Path.Combine(_overlayRoot, "settings.json")) ?? new JObject();
}
public void SaveSettings(JObject settings)
{
Directory.CreateDirectory(_overlayRoot);
string path = Path.Combine(_overlayRoot, "settings.json");
File.WriteAllText(path, (settings ?? new JObject()).ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
}
public JObject LoadOllamaRoles()
{
return TryReadJson(Path.Combine(_overlayRoot, "ollama-roles.json"))
?? new JObject { ["chat"] = new JArray(), ["memory"] = new JArray() };
}
public List ResolveEnabledSkills(string personaId, JArray clientSkills)
{
var catalog = ListSkills(personaId);
HashSet enabled = new(StringComparer.OrdinalIgnoreCase);
foreach (var s in catalog.Where(x => x.defaultOn))
{
enabled.Add(s.id);
}
JObject settings = LoadSettings();
string pid = SafeId(personaId) ?? "neutral";
if (settings[pid]?["skills"] is JObject perPersona)
{
foreach (JProperty prop in perPersona.Properties())
{
string id = SafeId(prop.Name);
if (id is null)
{
continue;
}
if (prop.Value.Type == JTokenType.Boolean)
{
if (prop.Value.Value())
{
enabled.Add(id);
}
else
{
enabled.Remove(id);
}
}
}
}
if (clientSkills is not null && clientSkills.Count > 0)
{
enabled.Clear();
foreach (JToken t in clientSkills)
{
string id = SafeId(t?.ToString());
if (id is not null && catalog.Any(c => string.Equals(c.id, id, StringComparison.OrdinalIgnoreCase)))
{
enabled.Add(id);
}
}
}
return catalog.Select(c => c.id).Where(enabled.Contains).ToList();
}
public JObject BuildMergedConfigPayload(string personaId)
{
string id = SafeId(personaId) ?? DefaultPersonaId();
JObject assistant = LoadAssistant(id);
JObject ui = LoadUi(id);
JObject model = LoadModelProfile(id);
JObject exact = LoadExact(id);
var packs = ListPacks(id);
var skills = ListSkills(id);
var personas = ListPersonaCatalog();
JObject identity = LoadIdentityParts(id);
JObject controlsSchema = LoadControlsSchema(id);
JObject controlValues = LoadControlValues(id);
return new JObject
{
["success"] = true,
["persona"] = id,
["default_persona"] = DefaultPersonaId(),
["assistant"] = assistant,
["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,
["title"] = p.title,
["order"] = p.order,
["aliases"] = new JArray(p.aliases),
})),
["skills"] = new JArray(skills.Select(s => new JObject
{
["id"] = s.id,
["title"] = s.title,
["default"] = s.defaultOn,
})),
["personas"] = new JArray(personas.Select(p => new JObject
{
["id"] = p.id,
["title"] = p.title,
["accent"] = p.accent,
["source"] = p.source,
})),
["identity"] = identity,
["identity_summary"] = RenderIdentityBlock(id),
["enabled_skills"] = new JArray(ResolveEnabledSkills(id, null)),
};
}
}