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 { return JObject.Parse(File.ReadAllText(path, Encoding.UTF8)); } 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", "search_query", "civitai_query", "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", "memories", "memory", "memory_query", "memory_kind", "tag_query", "user_prefs", "inventory_query", "skills", "persona_shelves", "persona_clone", "persona", "controls", "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; } /// 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)); } 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", }; 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 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)) { 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 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."); 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) { lock (_lock) { Directory.CreateDirectory(_overlayRoot); string path = Path.Combine(_overlayRoot, "settings.json"); JObject merged = DeepMerge(LoadSettings(), settings ?? new JObject()); File.WriteAllText(path, merged.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8); } } public JObject LoadTrainingRunner() => TryReadJson(Path.Combine(_overlayRoot, "training-runner.json")) ?? new JObject(); public void SaveTrainingRunner(JObject settings) { lock (_lock) { Directory.CreateDirectory(_overlayRoot); string path = Path.Combine(_overlayRoot, "training-runner.json"); JObject merged = DeepMerge(LoadTrainingRunner(), settings ?? new JObject()); File.WriteAllText(path, merged.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8); } } public JObject LoadTrainingAgent() => TryReadJson(Path.Combine(_overlayRoot, "training-agent.json")) ?? new JObject { ["enabled"] = true, ["auto_link_on_approve"] = true, ["heard_quota"] = 3, }; public void SaveTrainingAgent(JObject settings) { lock (_lock) { Directory.CreateDirectory(_overlayRoot); string path = Path.Combine(_overlayRoot, "training-agent.json"); JObject merged = DeepMerge(LoadTrainingAgent(), settings ?? new JObject()); File.WriteAllText(path, merged.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) { 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()), }; } }