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.Equals(s, "terse", StringComparison.OrdinalIgnoreCase))
{
s = "aggressive";
}
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
{
// Share read/write/delete: Windows otherwise fails a concurrent SaveSettings with a sharing violation.
using FileStream fs = new(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
using StreamReader reader = new(fs, Encoding.UTF8);
return JObject.Parse(reader.ReadToEnd());
}
catch (Exception ex)
{
Logs.Debug($"AssistentConfig json {path}: {ex.Message}");
return null;
}
}
public string[] LoadPatchKeys()
{
string path = ResolveUnder(_bundledRoot, "_base/patch-keys.json");
JObject doc = TryReadJson(path);
if (doc?["keys"] is JArray arr && arr.Count > 0)
{
return arr.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)).ToArray();
}
// Fallback if bundled json missing
return
[
"prompt", "negative", "loras", "width", "height", "steps", "cfg", "seed", "sigma_shift", "sampler", "scheduler",
"actions", "generate", "ask", "checkpoint",
"use_init_image", "clear_init_image", "init_creativity", "denoise",
"use_mask_image", "clear_mask_image", "mask_blur", "mask_grow",
"look_at", "vision_from", "vision_slots", "slot_to_init", "slot_to_mask",
"snapshot_generate", "select_slot", "aspect", "images", "batch", "vary", "lock_seed",
"creativity", "intensity", "complexity", "movement",
"clear_prompt_images", "slot_to_prompt_image", "pack", "persona", "controls",
"inventory_query", "variants",
];
}
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;
}
/// Installed git persona pack: id → shelf directory under Assistent/extensions/.
public sealed record PackPersona(string Id, string ShelfRoot);
/// Parse minimal assistent-pack.yaml (kind + id). No full YAML dependency.
public static Dictionary TryParsePackManifest(string path)
{
Dictionary map = new(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return map;
}
try
{
foreach (string rawLine in File.ReadAllLines(path, Encoding.UTF8))
{
string line = rawLine.Trim();
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#') || line.StartsWith("---"))
{
continue;
}
int colon = line.IndexOf(':');
if (colon <= 0)
{
continue;
}
string key = line[..colon].Trim();
string val = line[(colon + 1)..].Trim().Trim('"', '\'');
if (!string.IsNullOrWhiteSpace(key))
{
map[key] = val;
}
}
}
catch (Exception ex)
{
Logs.Debug($"AssistentConfig pack.yaml {path}: {ex.Message}");
}
return map;
}
static List ParseYamlBracketList(string value)
{
value = (value ?? "").Trim();
if (value.StartsWith('[') && value.EndsWith(']'))
{
value = value[1..^1];
}
return value.Split(',')
.Select(s => s.Trim().Trim('"', '\''))
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToList();
}
public string TryFindPackManifestPath(string personaId)
{
string shelf = PackShelfRoot(personaId);
if (string.IsNullOrWhiteSpace(shelf))
{
return null;
}
string dir = shelf;
for (int i = 0; i < 5 && !string.IsNullOrWhiteSpace(dir); i++)
{
string yaml = Path.Combine(dir, "assistent-pack.yaml");
if (File.Exists(yaml))
{
return yaml;
}
dir = Directory.GetParent(dir)?.FullName;
}
return null;
}
/// Pack manifest knowledge.attach list for a pack persona.
public List TryParsePackKnowledgeAttach(string personaId)
{
string path = TryFindPackManifestPath(personaId);
if (string.IsNullOrWhiteSpace(path))
{
return [];
}
List items = [];
try
{
foreach (string rawLine in File.ReadAllLines(path, Encoding.UTF8))
{
string line = rawLine.Trim();
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#'))
{
continue;
}
if (line.StartsWith("knowledge.attach:", StringComparison.OrdinalIgnoreCase))
{
string val = line["knowledge.attach:".Length..].Trim();
items.AddRange(ParseYamlBracketList(val));
break;
}
}
}
catch (Exception ex)
{
Logs.Debug($"AssistentConfig pack knowledge.attach {path}: {ex.Message}");
}
return items
.Select(SafeId)
.Where(id => id is not null)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
public JObject LoadKnowledgeAttachOverlay(string personaId)
{
string id = SafeId(personaId);
if (id is null)
{
return null;
}
string path = Path.Combine(_overlayRoot, "personas", id, "knowledge.json");
return File.Exists(path) ? TryReadJson(path) : null;
}
public void SaveKnowledgeAttachOverlay(string personaId, JArray attach)
{
lock (_lock)
{
string id = SafeId(personaId) ?? throw new InvalidOperationException("invalid persona");
string dir = Path.Combine(_overlayRoot, "personas", id);
Directory.CreateDirectory(dir);
string path = Path.Combine(dir, "knowledge.json");
JObject doc = new()
{
["attach"] = attach ?? new JArray(),
};
File.WriteAllText(path, doc.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
}
}
static readonly string[] DefaultBundledKnowledgeAttach = ["civitai-krea2", "ru-fictext-rplus"];
static readonly HashSet BundledKnowledgePersonas = new(StringComparer.OrdinalIgnoreCase)
{
"neutral", "aggressive", "dreamer",
};
/// Effective book attach list for a persona (overlay → pack → bundled defaults).
public List ResolveKnowledgeAttach(string personaId)
{
string pid = SafeId(personaId) ?? DefaultPersonaId();
JObject overlay = LoadKnowledgeAttachOverlay(pid);
if (overlay?["attach"] is JArray custom && custom.Count > 0)
{
return custom.Select(t => SafeId(t?.ToString()))
.Where(id => id is not null)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
List fromPack = TryParsePackKnowledgeAttach(pid);
if (fromPack.Count > 0)
{
return fromPack;
}
if (IsBundledPersona(pid) && BundledKnowledgePersonas.Contains(pid))
{
return DefaultBundledKnowledgeAttach.ToList();
}
return [];
}
/// Discover persona packs under Assistent/extensions/*/.
public List DiscoverPackPersonas()
{
List found = [];
HashSet seen = new(StringComparer.OrdinalIgnoreCase);
string extRoot = Path.Combine(_overlayRoot, "extensions");
if (!Directory.Exists(extRoot))
{
return found;
}
foreach (string packDir in Directory.GetDirectories(extRoot).OrderBy(d => d, StringComparer.OrdinalIgnoreCase))
{
Dictionary manifest = TryParsePackManifest(Path.Combine(packDir, "assistent-pack.yaml"));
string kind = "persona";
if (manifest.TryGetValue("kind", out string kindVal) && !string.IsNullOrWhiteSpace(kindVal))
{
kind = kindVal.Trim();
}
if (!string.Equals(kind, "persona", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(kind, "personas", StringComparison.OrdinalIgnoreCase))
{
continue;
}
string multi = Path.Combine(packDir, "personas");
if (Directory.Exists(multi))
{
foreach (string folder in Directory.GetDirectories(multi))
{
string id = SafeId(Path.GetFileName(folder));
if (id is null || !File.Exists(Path.Combine(folder, "persona.json")))
{
continue;
}
if (seen.Add(id))
{
found.Add(new PackPersona(id, folder));
}
}
continue;
}
string singleId = null;
if (manifest.TryGetValue("id", out string idVal))
{
singleId = SafeId(idVal);
}
if (singleId is null || !File.Exists(Path.Combine(packDir, "persona.json")))
{
continue;
}
if (seen.Add(singleId))
{
found.Add(new PackPersona(singleId, packDir));
}
}
return found;
}
public string PackShelfRoot(string personaId)
{
string id = SafeId(personaId);
if (id is null)
{
return null;
}
return DiscoverPackPersonas().FirstOrDefault(p => string.Equals(p.Id, id, StringComparison.OrdinalIgnoreCase))?.ShelfRoot;
}
JObject TryReadPersonaMeta(string personaId)
{
string id = SafeId(personaId);
if (id is null)
{
return null;
}
return TryReadJson(ResolveUnder(Path.Combine(_bundledRoot, "personas", id), "persona.json"))
?? TryReadJson(ResolveUnder(PackShelfRoot(id) ?? "", "persona.json"))
?? TryReadJson(ResolveUnder(Path.Combine(_overlayRoot, "personas", id), "persona.json"));
}
/// 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 = TryReadPersonaMeta(cur);
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)
{
Dictionary packs = DiscoverPackPersonas()
.ToDictionary(p => p.Id, p => p.ShelfRoot, StringComparer.OrdinalIgnoreCase);
yield return Path.Combine(_bundledRoot, "_base");
foreach (string id in PersonaExtendsChain(personaId))
{
yield return Path.Combine(_bundledRoot, "personas", id);
}
// Pack shelves sit between bundled and overlay (overlay Exact / edits win).
foreach (string id in PersonaExtendsChain(personaId))
{
if (packs.TryGetValue(id, out string shelf) && !string.IsNullOrWhiteSpace(shelf))
{
yield return shelf;
}
}
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 ScanFolder(string folder, string idHint, string source)
{
string id = SafeId(idHint) ?? SafeId(Path.GetFileName(folder));
if (id is null)
{
return;
}
JObject meta = TryReadJson(Path.Combine(folder, "persona.json"));
if (meta is null)
{
return;
}
if (meta["enabled"]?.Value() == false)
{
byId.Remove(id);
return;
}
byId[id] = (
meta["title"]?.ToString() ?? id,
meta["accent"]?.ToString() ?? "#8b949e",
source
);
}
void ScanPersonasRoot(string root, string source)
{
string dir = Path.Combine(root, "personas");
if (!Directory.Exists(dir))
{
return;
}
foreach (string folder in Directory.GetDirectories(dir))
{
ScanFolder(folder, Path.GetFileName(folder), source);
}
}
ScanPersonasRoot(_bundledRoot, "bundled");
foreach (PackPersona pack in DiscoverPackPersonas())
{
ScanFolder(pack.ShelfRoot, pack.Id, "pack");
}
ScanPersonasRoot(_overlayRoot, "overlay");
// Normalize source: overlay / pack / bundled combinations.
foreach (string id in byId.Keys.ToList())
{
var cur = byId[id];
byId[id] = (cur.title, cur.accent, PersonaSource(id));
}
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";
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", "knowledge.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)
{
lock (_lock)
{
string id = SafeId(personaId) ?? "neutral";
JObject schema = LoadControlsSchema(id);
string dir = Path.Combine(_overlayRoot, "personas", id);
Directory.CreateDirectory(dir);
string path = Path.Combine(dir, "exact.json");
JObject existing = TryReadJson(path) ?? new JObject();
JObject prev = existing["controls"] as JObject ?? new JObject();
JObject clamped = ClampControls(schema, values ?? new JObject());
existing["controls"] = DeepMerge(prev, 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;
}
///
/// Drop control keys that merely restate schema defaults while the user already has a
/// different saved value — models often echo Exact defaults inside patches and that was
/// resetting Хорни / Вкус after every reply.
/// Always ignore default-echo when current differs (Generate or not). Intentional resets
/// go through AssistentSaveControls / controls-only patches with a non-default target, or
/// Generate patches are stripped of controls entirely in ApplyPersonaActions.
///
public static JObject FilterEchoedControlDefaults(JObject schema, JObject current, JObject incoming, bool patchLooksLikeGen)
{
JObject result = new();
if (schema is null || incoming is null)
{
return result;
}
// Generate patches must not mutate persona sliders — strip everything.
if (patchLooksLikeGen)
{
return result;
}
current ??= new JObject();
foreach (JProperty prop in incoming.Properties())
{
if (schema[prop.Name] is not JObject def)
{
continue;
}
if (!double.TryParse(prop.Value?.ToString(), System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out double incomingVal))
{
continue;
}
double defVal = def["default"]?.Value() ?? double.NaN;
double curVal = double.NaN;
if (current[prop.Name] != null)
{
double.TryParse(current[prop.Name]?.ToString(), System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out curVal);
}
if (!double.IsNaN(curVal) && Math.Abs(incomingVal - curVal) < 0.0005)
{
continue;
}
// Default echo while the user has a tuned value — ignore even on controls-only patches.
if (!double.IsNaN(defVal)
&& Math.Abs(incomingVal - defVal) < 0.0005
&& !double.IsNaN(curVal)
&& Math.Abs(curVal - defVal) > 0.0005)
{
continue;
}
result[prop.Name] = Math.Round(incomingVal, 4);
}
return result;
}
public static bool PatchLooksLikeGeneration(JObject patch)
{
if (patch is null)
{
return false;
}
if (patch["prompt"] != null || patch["loras"] != null || patch["aspect"] != null
|| patch["width"] != null || patch["height"] != null || patch["steps"] != null
|| patch["cfg"] != null || patch["seed"] != null
|| patch["variants"] != null)
{
return true;
}
if (patch["actions"] is JArray acts)
{
foreach (JToken a in acts)
{
if (string.Equals(a?.ToString(), "generate", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
return false;
}
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 via patch.controls)");
sb.AppendLine("Any slider declared in this persona's controls.json appears in the UI automatically. Respect current numbers.");
foreach (JProperty prop in schema.Properties().OrderBy(p =>
{
double order = (p.Value as JObject)?["order"]?.Value() ?? 100;
return order;
}).ThenBy(p => p.Name, StringComparer.OrdinalIgnoreCase))
{
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() ?? "";
string display = def["display"]?.ToString() ?? "";
string curText = string.Equals(display, "percent", StringComparison.OrdinalIgnoreCase)
? $"{cur.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture)}%"
: cur.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture);
sb.AppendLine($"- **{prop.Name}** ({label}): {curText} (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 IsPackPersona(string personaId)
{
return PackShelfRoot(personaId) is not null;
}
/// Bundled or installed pack — not deletable / not overwritten in place.
public bool IsProtectedPersona(string personaId)
=> IsBundledPersona(personaId) || IsPackPersona(personaId);
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)
{
bool overlay = IsOverlayPersona(personaId);
bool bundled = IsBundledPersona(personaId);
bool pack = IsPackPersona(personaId);
if (overlay && bundled)
{
return "overlay+bundled";
}
if (overlay && pack)
{
return "overlay+pack";
}
if (overlay)
{
return "overlay";
}
if (pack)
{
return "pack";
}
if (bundled)
{
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", "knowledge.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 (IsProtectedPersona(to))
{
throw new InvalidOperationException($"cannot overwrite protected 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 (IsProtectedPersona(id) && !allowBundledShadow && !IsOverlayPersona(id))
{
throw new InvalidOperationException($"cannot write protected 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 (IsProtectedPersona(id) && !IsOverlayPersona(id))
{
throw new InvalidOperationException($"cannot delete protected persona '{id}'");
}
// Only delete overlay folder; never touch bundled or pack installs.
string dest = Path.Combine(_overlayRoot, "personas", id);
if (!Directory.Exists(dest))
{
return false;
}
if (IsProtectedPersona(id))
{
// Overlay shadow of a bundled/pack id — remove overlay only (reverts to base).
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 (IsProtectedPersona(id) && !IsOverlayPersona(id))
{
// Force rename away from bundled/pack id
id = SafeId(id + "_import") ?? throw new InvalidOperationException("cannot import over protected id — pick a new id");
if (IsProtectedPersona(id) || (IsOverlayPersona(id) && !overwriteOverlay))
{
throw new InvalidOperationException($"id '{requested}' is protected — 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 hidden = meta["hidden"]?.Value() == true;
if (hidden)
{
byId.Remove(id);
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);
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}*");
}
sb.AppendLine($"**You are {title}** (the assistant in this chat). The human you talk to is the **user** — a different person.");
sb.AppendLine($"Never address or name the user «{title}» unless `## About the user` explicitly says that is their name.");
sb.AppendLine($"If asked your name («как тебя зовут?» / «who are you?»), answer with **{title}** — do not greet the user by that name instead.");
sb.AppendLine("Do not greet or re-introduce yourself on later turns. If the chat already has your messages, skip hello/bio and answer directly.");
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);
HashSet ids = new(StringComparer.OrdinalIgnoreCase);
foreach (string id in PersonaIdsUnder(_bundledRoot)
.Concat(PersonaIdsUnder(_overlayRoot))
.Concat(DiscoverPackPersonas().Select(p => p.Id)))
{
ids.Add(id);
}
foreach (string id in ids)
{
Scan(Path.Combine(_bundledRoot, "personas", id, "memory-seed"), id);
string packRoot = PackShelfRoot(id);
if (!string.IsNullOrWhiteSpace(packRoot))
{
Scan(Path.Combine(packRoot, "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)
{
lock (_lock)
{
Directory.CreateDirectory(_overlayRoot);
string path = Path.Combine(_overlayRoot, "settings.json");
JObject merged = DeepMerge(LoadSettings(), settings ?? new JObject());
WriteFileAtomic(path, merged.ToString(Newtonsoft.Json.Formatting.Indented));
}
}
/// Temp file + rename, retried briefly: readers never see a half-written file and a
/// transient lock (antivirus, editor, parallel read) does not fail the API call.
static void WriteFileAtomic(string path, string text)
{
string tmp = $"{path}.{Guid.NewGuid():N}.tmp";
File.WriteAllText(tmp, text, Encoding.UTF8);
for (int attempt = 0; ; attempt++)
{
try
{
File.Move(tmp, path, overwrite: true);
return;
}
catch (IOException) when (attempt < 10)
{
System.Threading.Thread.Sleep(25 * (attempt + 1));
}
catch
{
try { File.Delete(tmp); } catch { }
throw;
}
}
}
public JObject LoadOllamaRoles()
{
return TryReadJson(Path.Combine(_overlayRoot, "ollama-roles.json"))
?? new JObject { ["chat"] = new JArray(), ["memory"] = new JArray() };
}
/// Heard-examples RAG knobs (legacy training-agent.json on data volume).
public JObject LoadTrainingAgent()
=> TryReadJson(Path.Combine(_overlayRoot, "training-agent.json"))
?? new JObject
{
["enabled"] = true,
["auto_link_on_approve"] = true,
["heard_quota"] = 3,
};
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)
{
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, includeAllShelves: true),
["enabled_skills"] = new JArray(ResolveEnabledSkills(id, null)),
["patch_keys"] = new JArray(LoadPatchKeys()),
["knowledge"] = new JObject
{
["attach"] = new JArray(ResolveKnowledgeAttach(id)),
},
};
}
}