Index Assistent/books search.jsonl, expose knowledge hops/catalog in chat, and drop QLoRA/HF training stack. Co-authored-by: Cursor <cursoragent@cursor.com>
1787 lines
68 KiB
C#
1787 lines
68 KiB
C#
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;
|
|
|
|
/// <summary>Loads Config/_base + personas/<id> sparse presets with disk overlay merge.</summary>
|
|
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", "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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Merge layered copies of the same relative path: bundled base → persona chain → disk base → disk persona.</summary>
|
|
public JObject MergeJsonLayers(string relative, IEnumerable<string> 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<string> 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;
|
|
}
|
|
|
|
/// <summary>Installed git persona pack: id → shelf directory under Assistent/extensions/.</summary>
|
|
public sealed record PackPersona(string Id, string ShelfRoot);
|
|
|
|
/// <summary>Parse minimal assistent-pack.yaml (kind + id). No full YAML dependency.</summary>
|
|
public static Dictionary<string, string> TryParsePackManifest(string path)
|
|
{
|
|
Dictionary<string, string> 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<string> 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;
|
|
}
|
|
|
|
/// <summary>Pack manifest knowledge.attach list for a pack persona.</summary>
|
|
public List<string> TryParsePackKnowledgeAttach(string personaId)
|
|
{
|
|
string path = TryFindPackManifestPath(personaId);
|
|
if (string.IsNullOrWhiteSpace(path))
|
|
{
|
|
return [];
|
|
}
|
|
List<string> 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<string> BundledKnowledgePersonas = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"neutral", "aggressive", "dreamer",
|
|
};
|
|
|
|
/// <summary>Effective book attach list for a persona (overlay → pack → bundled defaults).</summary>
|
|
public List<string> 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<string> fromPack = TryParsePackKnowledgeAttach(pid);
|
|
if (fromPack.Count > 0)
|
|
{
|
|
return fromPack;
|
|
}
|
|
if (IsBundledPersona(pid) && BundledKnowledgePersonas.Contains(pid))
|
|
{
|
|
return DefaultBundledKnowledgeAttach.ToList();
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/// <summary>Discover persona packs under Assistent/extensions/*/.</summary>
|
|
public List<PackPersona> DiscoverPackPersonas()
|
|
{
|
|
List<PackPersona> found = [];
|
|
HashSet<string> 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<string, string> 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"));
|
|
}
|
|
|
|
/// <summary>Ancestor-first chain ending with the current persona (for overlay merge and vector retrieve).</summary>
|
|
public List<string> PersonaExtendsChain(string personaId)
|
|
{
|
|
List<string> chain = [];
|
|
HashSet<string> 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<string> LayerRoots(string personaId)
|
|
{
|
|
Dictionary<string, string> 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<string, (string title, string accent, string source)> 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<bool?>() == 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;
|
|
}
|
|
|
|
/// <summary>Exact KV for the system prompt: params tables + persona controls (facts stay short / in RAG).</summary>
|
|
public JObject LoadExactForPrompt(string personaId)
|
|
{
|
|
JObject full = LoadExact(personaId);
|
|
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<string> ReservedConfigFiles = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"exact.json", "controls.json", "skills.json", "ui.json", "assistant.json", "knowledge.json",
|
|
};
|
|
|
|
static readonly HashSet<string> ReservedConfigDirs = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"memory-seed", "packs", "skills", "core", "models",
|
|
};
|
|
|
|
/// <summary>Identity shelf filenames (*.json) discovered under layer roots, excluding reserved config.</summary>
|
|
public List<string> DiscoverIdentityShelfFiles(string personaId)
|
|
{
|
|
HashSet<string> names = new(StringComparer.OrdinalIgnoreCase);
|
|
foreach (string root in LayerRoots(personaId))
|
|
{
|
|
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
|
|
{
|
|
continue;
|
|
}
|
|
foreach (string file in Directory.GetFiles(root, "*.json"))
|
|
{
|
|
string name = Path.GetFileName(file);
|
|
if (ReservedConfigFiles.Contains(name))
|
|
{
|
|
continue;
|
|
}
|
|
names.Add(name);
|
|
}
|
|
}
|
|
// Stable order: persona first, then alpha.
|
|
return names.OrderBy(n => string.Equals(n, "persona.json", StringComparison.OrdinalIgnoreCase) ? 0 : 1)
|
|
.ThenBy(n => n, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
}
|
|
|
|
public JObject LoadControlsSchema(string personaId) => MergeJsonLayers("controls.json", LayerRoots(personaId));
|
|
|
|
public JObject LoadControlValues(string personaId)
|
|
{
|
|
JObject exact = LoadExact(personaId);
|
|
return exact["controls"] as JObject ?? new JObject();
|
|
}
|
|
|
|
/// <summary>Clamp and merge control values into overlay exact.json (controls key only).</summary>
|
|
public JObject SaveControlValues(string personaId, JObject values)
|
|
{
|
|
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<double?>() ?? -1;
|
|
double max = def["max"]?.Value<double?>() ?? 1;
|
|
if (v < min)
|
|
{
|
|
v = min;
|
|
}
|
|
if (v > max)
|
|
{
|
|
v = max;
|
|
}
|
|
result[prop.Name] = Math.Round(v, 4);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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?>() ?? 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<double?>() ?? 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<double?>() ?? -1;
|
|
double max = def["max"]?.Value<double?>() ?? 1;
|
|
double defVal = def["default"]?.Value<double?>() ?? 0;
|
|
double cur = values[prop.Name]?.Value<double?>() ?? defVal;
|
|
string label = def["label"]?.ToString() ?? prop.Name;
|
|
string hint = def["hint"]?.ToString() ?? "";
|
|
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;
|
|
}
|
|
|
|
/// <summary>Bundled or installed pack — not deletable / not overwritten in place.</summary>
|
|
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<string> WritableShelfNames = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"persona.json", "bio.json", "voice.json", "humor.json", "craft.json",
|
|
"appearance.json", "outfits.json", "roleplay.json", "likes.json", "dislikes.json",
|
|
"rules.json", "controls.json", "exact.json", "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);
|
|
}
|
|
|
|
/// <summary>Materialize merged identity shelves (+ controls/exact.controls) into overlay personas/toId.</summary>
|
|
public JObject ClonePersonaToOverlay(string fromId, string toId, string title, bool overwrite = false)
|
|
{
|
|
string from = SafeId(fromId) ?? throw new ArgumentException("invalid from id");
|
|
string to = SafeId(toId) ?? throw new ArgumentException("invalid to id");
|
|
if (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" };
|
|
}
|
|
|
|
/// <summary>Sparse DeepMerge shelves into overlay personas/id. Overlay-only ids (or existing overlay).</summary>
|
|
public JObject SavePersonaShelves(string personaId, JObject shelves, bool allowBundledShadow = false)
|
|
{
|
|
string id = SafeId(personaId) ?? throw new ArgumentException("invalid persona id");
|
|
if (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));
|
|
|
|
/// <summary>Exact (KV) memory: bundled base → persona overlays → disk overlays. Persona keys overwrite base.</summary>
|
|
public JObject LoadExact(string personaId) => MergeJsonLayers("exact.json", LayerRoots(personaId));
|
|
|
|
/// <summary>DeepMerge sparse keys into Assistent/_base/<fileName> (disk overlay only).</summary>
|
|
public JObject MergeOverlayBaseJson(string fileName, JObject sparse)
|
|
{
|
|
string safe = Path.GetFileName(fileName ?? "");
|
|
if (string.IsNullOrWhiteSpace(safe) || !safe.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
throw new ArgumentException("invalid overlay base json name");
|
|
}
|
|
string dir = Path.Combine(_overlayRoot, "_base");
|
|
Directory.CreateDirectory(dir);
|
|
string path = Path.Combine(dir, safe);
|
|
JObject existing = TryReadJson(path) ?? new JObject();
|
|
JObject merged = DeepMerge(existing, sparse ?? new JObject());
|
|
File.WriteAllText(path, merged.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
|
|
return merged;
|
|
}
|
|
|
|
/// <summary>Export a shareable persona pack (merged shelves + controls + personal memory-seed).</summary>
|
|
public JObject ExportPersonaPack(string personaId)
|
|
{
|
|
string id = SafeId(personaId) ?? throw new ArgumentException("invalid persona id");
|
|
JObject shelves = LoadIdentityParts(id);
|
|
JObject controls = LoadControlsSchema(id);
|
|
JObject controlValues = LoadControlValues(id);
|
|
JArray seed = [];
|
|
foreach (JObject doc in LoadMemorySeedDocs())
|
|
{
|
|
string persona = AssistentMemory.NormalizePersona(doc["persona"]?.ToString());
|
|
if (!string.Equals(persona, id, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
string kind = doc["kind"]?.ToString() ?? "note";
|
|
string key = doc["key"]?.ToString() ?? "";
|
|
string text = doc["text"]?.ToString() ?? "";
|
|
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(text))
|
|
{
|
|
continue;
|
|
}
|
|
seed.Add(new JObject
|
|
{
|
|
["kind"] = kind,
|
|
["key"] = key,
|
|
["text"] = text,
|
|
});
|
|
}
|
|
JObject metaShelf = shelves["persona"] as JObject ?? new JObject();
|
|
return new JObject
|
|
{
|
|
["format"] = "swarm-assistent-persona",
|
|
["version"] = 1,
|
|
["id"] = id,
|
|
["title"] = metaShelf["title"]?.ToString() ?? id,
|
|
["shelves"] = shelves,
|
|
["exact_controls"] = controlValues,
|
|
["controls_schema"] = controls,
|
|
["memory_seed"] = seed,
|
|
};
|
|
}
|
|
|
|
/// <summary>Import pack into overlay personas/<id>. Never overwrites bundled files.</summary>
|
|
public JObject ImportPersonaPack(JObject pack, string newId = null, bool overwriteOverlay = false)
|
|
{
|
|
if (pack is null || !string.Equals(pack["format"]?.ToString(), "swarm-assistent-persona", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
throw new ArgumentException("invalid persona pack format");
|
|
}
|
|
string requested = SafeId(newId) ?? SafeId(pack["id"]?.ToString());
|
|
if (requested is null)
|
|
{
|
|
throw new ArgumentException("invalid persona id");
|
|
}
|
|
string id = requested;
|
|
if (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<string, (string title, int order, string[] aliases, bool enabled)> 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<bool?>() == true;
|
|
if (hidden)
|
|
{
|
|
byId.Remove(id);
|
|
continue;
|
|
}
|
|
bool enabled = meta["enabled"]?.Value<bool?>() != 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<int?>() ?? 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<bool?>() == 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<string, (string title, bool defaultOn, bool enabled)> 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<bool?>() ?? false,
|
|
meta["enabled"]?.Value<bool?>() != 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<bool>(), 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<bool?>() == 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"];
|
|
|
|
/// <summary>Shelves always injected into the system prompt. Lore shelves load via persona_read hop.</summary>
|
|
public HashSet<string> IdentityAlwaysShelves(string personaId)
|
|
{
|
|
HashSet<string> set = new(StringComparer.OrdinalIgnoreCase);
|
|
JObject asst = LoadAssistant(SafeId(personaId) ?? "neutral") ?? new JObject();
|
|
if (asst["identity_always_shelves"] is JArray arr && arr.Count > 0)
|
|
{
|
|
foreach (JToken t in arr)
|
|
{
|
|
string name = SafeId(t?.ToString()) ?? t?.ToString()?.Trim();
|
|
if (!string.IsNullOrWhiteSpace(name))
|
|
{
|
|
set.Add(name);
|
|
}
|
|
}
|
|
}
|
|
if (set.Count == 0)
|
|
{
|
|
foreach (string s in DefaultIdentityAlwaysShelves)
|
|
{
|
|
set.Add(s);
|
|
}
|
|
}
|
|
return set;
|
|
}
|
|
|
|
/// <summary>Render always-on identity (voice/taste). Pass includeAllShelves for author pack / persona_read.</summary>
|
|
public string RenderIdentityBlock(string personaId, bool includeAllShelves = false, IEnumerable<string> onlyShelves = null)
|
|
{
|
|
string id = SafeId(personaId) ?? "neutral";
|
|
JObject parts = LoadIdentityParts(id);
|
|
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<string> allow = null;
|
|
if (onlyShelves is not null)
|
|
{
|
|
allow = new HashSet<string>(onlyShelves.Where(s => !string.IsNullOrWhiteSpace(s)), StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
else if (!includeAllShelves)
|
|
{
|
|
allow = IdentityAlwaysShelves(id);
|
|
}
|
|
|
|
foreach (JProperty prop in parts.Properties())
|
|
{
|
|
if (prop.Name is "persona" or "extra")
|
|
{
|
|
continue;
|
|
}
|
|
if (allow is not null && !allow.Contains(prop.Name))
|
|
{
|
|
continue;
|
|
}
|
|
if (prop.Value is not JObject shelf || !shelf.Properties().Any())
|
|
{
|
|
continue;
|
|
}
|
|
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();
|
|
}
|
|
|
|
/// <summary>Markdown for lore shelves not in always-on (persona_read hop).</summary>
|
|
public string RenderPersonaReadBlock(string personaId, IEnumerable<string> shelfNames)
|
|
{
|
|
string id = SafeId(personaId) ?? "neutral";
|
|
HashSet<string> always = IdentityAlwaysShelves(id);
|
|
List<string> want = [];
|
|
if (shelfNames is not null)
|
|
{
|
|
foreach (string raw in shelfNames)
|
|
{
|
|
string name = SafeId(raw) ?? raw?.Trim();
|
|
if (!string.IsNullOrWhiteSpace(name) && !always.Contains(name) && !string.Equals(name, "persona", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
want.Add(name);
|
|
}
|
|
}
|
|
}
|
|
if (want.Count == 0)
|
|
{
|
|
JObject parts = LoadIdentityParts(id);
|
|
foreach (JProperty prop in parts.Properties())
|
|
{
|
|
if (prop.Name is "persona" or "extra")
|
|
{
|
|
continue;
|
|
}
|
|
if (always.Contains(prop.Name))
|
|
{
|
|
continue;
|
|
}
|
|
if (prop.Value is JObject shelf && shelf.Properties().Any())
|
|
{
|
|
want.Add(prop.Name);
|
|
}
|
|
}
|
|
string extra = parts["extra"]?.ToString() ?? "";
|
|
if (!string.IsNullOrWhiteSpace(extra))
|
|
{
|
|
// RenderIdentityBlock with onlyShelves skips extra; append manually below if needed.
|
|
}
|
|
}
|
|
if (want.Count == 0)
|
|
{
|
|
JObject parts = LoadIdentityParts(id);
|
|
string extraOnly = parts["extra"]?.ToString() ?? "";
|
|
if (string.IsNullOrWhiteSpace(extraOnly))
|
|
{
|
|
return "";
|
|
}
|
|
return $"## Persona lore: {id}\n\n{extraOnly.Trim()}";
|
|
}
|
|
string body = RenderIdentityBlock(id, includeAllShelves: false, onlyShelves: want);
|
|
JObject all = LoadIdentityParts(id);
|
|
string extraMd = all["extra"]?.ToString() ?? "";
|
|
if (!string.IsNullOrWhiteSpace(extraMd) && (shelfNames is null || !shelfNames.Any() || shelfNames.Any(s => string.Equals(s, "extra", StringComparison.OrdinalIgnoreCase))))
|
|
{
|
|
body = string.IsNullOrWhiteSpace(body) ? extraMd.Trim() : body + "\n\n" + extraMd.Trim();
|
|
}
|
|
return body;
|
|
}
|
|
|
|
static IEnumerable<string> PersonaIdsUnder(string root)
|
|
{
|
|
string dir = Path.Combine(root ?? "", "personas");
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
public List<JObject> LoadMemorySeedDocs()
|
|
{
|
|
List<JObject> 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<JObject> items = parsed is JArray arr
|
|
? arr.OfType<JObject>()
|
|
: 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<string> 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());
|
|
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<string> ResolveEnabledSkills(string personaId, JArray clientSkills)
|
|
{
|
|
var catalog = ListSkills(personaId);
|
|
HashSet<string> 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<bool>())
|
|
{
|
|
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)),
|
|
},
|
|
};
|
|
}
|
|
}
|