Stop duplicating Exact into live context, tighten preview URL matching, force write_prompt on "поправь", and gate critique behind vision + a real JSON fence. Co-authored-by: Cursor <cursoragent@cursor.com>
798 lines
28 KiB
C#
798 lines
28 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.IsNullOrWhiteSpace(s) || !Regex.IsMatch(s, @"^[A-Za-z0-9][A-Za-z0-9_\-]{0,63}$"))
|
|
{
|
|
return null;
|
|
}
|
|
return s;
|
|
}
|
|
|
|
static string SafeRel(string relative)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(relative))
|
|
{
|
|
return null;
|
|
}
|
|
string norm = relative.Replace('\\', '/').TrimStart('/');
|
|
if (norm.Contains("..", StringComparison.Ordinal) || Path.IsPathRooted(relative))
|
|
{
|
|
return null;
|
|
}
|
|
return norm.Replace('/', Path.DirectorySeparatorChar);
|
|
}
|
|
|
|
public string ResolveUnder(string root, string relative)
|
|
{
|
|
string rel = SafeRel(relative);
|
|
if (rel is null || string.IsNullOrWhiteSpace(root))
|
|
{
|
|
return null;
|
|
}
|
|
string full = Path.GetFullPath(Path.Combine(root, rel));
|
|
string rootFull = Path.GetFullPath(root);
|
|
if (!full.StartsWith(rootFull.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|
|
&& !string.Equals(full, rootFull, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return null;
|
|
}
|
|
return full;
|
|
}
|
|
|
|
public JObject DeepMerge(JObject bottom, JObject top)
|
|
{
|
|
if (bottom is null)
|
|
{
|
|
return top is null ? new JObject() : (JObject)top.DeepClone();
|
|
}
|
|
if (top is null)
|
|
{
|
|
return (JObject)bottom.DeepClone();
|
|
}
|
|
JObject result = (JObject)bottom.DeepClone();
|
|
foreach (JProperty prop in top.Properties())
|
|
{
|
|
if (prop.Value is JObject topObj && result[prop.Name] is JObject botObj)
|
|
{
|
|
result[prop.Name] = DeepMerge(botObj, topObj);
|
|
}
|
|
else if (prop.Value is JArray || prop.Value is null || prop.Value.Type == JTokenType.Null)
|
|
{
|
|
// Arrays replace entirely when the top file provides them.
|
|
if (prop.Value is not null && prop.Value.Type != JTokenType.Null)
|
|
{
|
|
result[prop.Name] = prop.Value.DeepClone();
|
|
}
|
|
}
|
|
else if (prop.Value.Type == JTokenType.String && string.IsNullOrWhiteSpace(prop.Value.ToString()))
|
|
{
|
|
// Empty string does not clobber (personas.json empty prompt rule).
|
|
continue;
|
|
}
|
|
else
|
|
{
|
|
result[prop.Name] = prop.Value.DeepClone();
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
public JObject TryReadJson(string path)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
|
{
|
|
return null;
|
|
}
|
|
try
|
|
{
|
|
return JObject.Parse(File.ReadAllText(path, Encoding.UTF8));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logs.Debug($"AssistentConfig json {path}: {ex.Message}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public JArray TryReadJsonArray(string path)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
|
{
|
|
return null;
|
|
}
|
|
try
|
|
{
|
|
return JArray.Parse(File.ReadAllText(path, Encoding.UTF8));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logs.Debug($"AssistentConfig json-array {path}: {ex.Message}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public string TryReadText(string path)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
|
{
|
|
return null;
|
|
}
|
|
try
|
|
{
|
|
string text = File.ReadAllText(path, Encoding.UTF8);
|
|
return string.IsNullOrWhiteSpace(text) ? null : text;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logs.Debug($"AssistentConfig text {path}: {ex.Message}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
|
|
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 = 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<string> 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<string, (string title, string accent, string source)> 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<bool?>() == false)
|
|
{
|
|
byId.Remove(id);
|
|
continue;
|
|
}
|
|
byId[id] = (
|
|
meta["title"]?.ToString() ?? id,
|
|
meta["accent"]?.ToString() ?? "#8b949e",
|
|
source
|
|
);
|
|
}
|
|
}
|
|
Scan(_bundledRoot, "bundled");
|
|
Scan(_overlayRoot, "overlay");
|
|
|
|
// Legacy personas.json titles
|
|
string overlayJson = Path.Combine(_overlayRoot, "personas.json");
|
|
JObject legacy = TryReadJson(overlayJson);
|
|
if (legacy?["personas"] is JArray arr)
|
|
{
|
|
foreach (JToken t in arr)
|
|
{
|
|
if (t is not JObject po)
|
|
{
|
|
continue;
|
|
}
|
|
string id = SafeId(po["id"]?.ToString());
|
|
if (id is null)
|
|
{
|
|
continue;
|
|
}
|
|
if (byId.TryGetValue(id, out var cur))
|
|
{
|
|
byId[id] = (po["title"]?.ToString() ?? cur.title, cur.accent, "overlay+bundled");
|
|
}
|
|
else
|
|
{
|
|
byId[id] = (po["title"]?.ToString() ?? id, "#8b949e", "legacy");
|
|
}
|
|
}
|
|
}
|
|
|
|
return byId.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
|
|
.Select(kv => (kv.Key, kv.Value.title, kv.Value.accent, kv.Value.source))
|
|
.ToList();
|
|
}
|
|
|
|
public string DefaultPersonaId()
|
|
{
|
|
JObject assistant = MergeJsonLayers("assistant.json", LayerRoots("neutral"));
|
|
string def = SafeId(assistant["default_persona"]?.ToString()) ?? "neutral";
|
|
string overlayJson = Path.Combine(_overlayRoot, "personas.json");
|
|
JObject legacy = TryReadJson(overlayJson);
|
|
string fromLegacy = SafeId(legacy?["default"]?.ToString());
|
|
if (fromLegacy is not null)
|
|
{
|
|
def = fromLegacy;
|
|
}
|
|
var catalog = ListPersonaCatalog();
|
|
if (catalog.All(p => !string.Equals(p.id, def, StringComparison.OrdinalIgnoreCase)) && catalog.Count > 0)
|
|
{
|
|
def = catalog[0].id;
|
|
}
|
|
return def;
|
|
}
|
|
|
|
/// <summary>Exact KV for the system prompt: params tables only (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" })
|
|
{
|
|
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;
|
|
}
|
|
|
|
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));
|
|
|
|
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 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();
|
|
JObject persona = MergeJsonLayers("persona.json", roots);
|
|
JObject voice = MergeJsonLayers("voice.json", roots);
|
|
JObject likes = MergeJsonLayers("likes.json", roots);
|
|
JObject dislikes = MergeJsonLayers("dislikes.json", roots);
|
|
JObject rules = MergeJsonLayers("rules.json", roots);
|
|
string extra = MergeTextLayers("extra.md", roots);
|
|
|
|
// Legacy personas.json prompt → extra overlay
|
|
string overlayJson = Path.Combine(_overlayRoot, "personas.json");
|
|
JObject legacy = TryReadJson(overlayJson);
|
|
if (legacy?["personas"] is JArray arr)
|
|
{
|
|
string id = SafeId(personaId) ?? "neutral";
|
|
foreach (JToken t in arr)
|
|
{
|
|
if (t is JObject po && string.Equals(SafeId(po["id"]?.ToString()), id, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
string title = po["title"]?.ToString();
|
|
if (!string.IsNullOrWhiteSpace(title))
|
|
{
|
|
persona["title"] = title;
|
|
}
|
|
string prompt = po["prompt"]?.ToString();
|
|
if (!string.IsNullOrWhiteSpace(prompt))
|
|
{
|
|
extra = string.IsNullOrWhiteSpace(extra) ? prompt : extra + "\n\n" + prompt;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return new JObject
|
|
{
|
|
["persona"] = persona,
|
|
["voice"] = voice,
|
|
["likes"] = likes,
|
|
["dislikes"] = dislikes,
|
|
["rules"] = rules,
|
|
["extra"] = extra ?? "",
|
|
};
|
|
}
|
|
|
|
static string FormatCategoryMap(JObject obj)
|
|
{
|
|
if (obj is null || !obj.Properties().Any())
|
|
{
|
|
return "";
|
|
}
|
|
List<string> parts = [];
|
|
foreach (JProperty prop in obj.Properties())
|
|
{
|
|
if (prop.Value is JArray arr)
|
|
{
|
|
string joined = string.Join(", ", arr.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)));
|
|
if (!string.IsNullOrWhiteSpace(joined))
|
|
{
|
|
parts.Add($"{prop.Name}: {joined}");
|
|
}
|
|
}
|
|
else if (prop.Value?.Type == JTokenType.String)
|
|
{
|
|
string s = prop.Value.ToString();
|
|
if (!string.IsNullOrWhiteSpace(s))
|
|
{
|
|
parts.Add($"{prop.Name}: {s}");
|
|
}
|
|
}
|
|
}
|
|
return string.Join("; ", parts);
|
|
}
|
|
|
|
public string RenderIdentityBlock(string personaId)
|
|
{
|
|
string id = SafeId(personaId) ?? "neutral";
|
|
JObject parts = LoadIdentityParts(id);
|
|
JObject persona = parts["persona"] as JObject ?? new JObject();
|
|
JObject voice = parts["voice"] as JObject ?? new JObject();
|
|
JObject likes = parts["likes"] as JObject ?? new JObject();
|
|
JObject dislikes = parts["dislikes"] as JObject ?? new JObject();
|
|
JObject rules = parts["rules"] as JObject ?? new JObject();
|
|
string extra = parts["extra"]?.ToString() ?? "";
|
|
|
|
string title = persona["title"]?.ToString() ?? id;
|
|
StringBuilder sb = new();
|
|
sb.AppendLine($"## Persona: {id} — {title}");
|
|
|
|
List<string> voiceBits = [];
|
|
if (voice["verbosity"] != null)
|
|
{
|
|
voiceBits.Add(voice["verbosity"].ToString());
|
|
}
|
|
if (voice["tone"] is JArray tones)
|
|
{
|
|
voiceBits.AddRange(tones.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)));
|
|
}
|
|
if (voice["humor"] != null && voice["humor"].ToString() != "none")
|
|
{
|
|
voiceBits.Add($"humor:{voice["humor"]}");
|
|
}
|
|
if (voice["nsfw"] != null)
|
|
{
|
|
voiceBits.Add($"NSFW {voice["nsfw"]}");
|
|
}
|
|
if (voice["language"] != null)
|
|
{
|
|
voiceBits.Add(voice["language"].ToString());
|
|
}
|
|
if (voiceBits.Count > 0)
|
|
{
|
|
sb.AppendLine("Voice: " + string.Join(", ", voiceBits));
|
|
}
|
|
|
|
string prefers = FormatCategoryMap(likes);
|
|
if (!string.IsNullOrWhiteSpace(prefers))
|
|
{
|
|
sb.AppendLine("Prefers: " + prefers);
|
|
}
|
|
string avoids = FormatCategoryMap(dislikes);
|
|
if (!string.IsNullOrWhiteSpace(avoids))
|
|
{
|
|
sb.AppendLine("Avoids: " + avoids);
|
|
}
|
|
|
|
List<string> ruleBits = [];
|
|
if (rules["always"] is JArray always)
|
|
{
|
|
foreach (string s in always.Select(t => t?.ToString()).Where(x => !string.IsNullOrWhiteSpace(x)))
|
|
{
|
|
ruleBits.Add("always " + s);
|
|
}
|
|
}
|
|
if (rules["never"] is JArray never)
|
|
{
|
|
foreach (string s in never.Select(t => t?.ToString()).Where(x => !string.IsNullOrWhiteSpace(x)))
|
|
{
|
|
ruleBits.Add("never " + s);
|
|
}
|
|
}
|
|
if (ruleBits.Count > 0)
|
|
{
|
|
sb.AppendLine("Rules: " + string.Join("; ", ruleBits));
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(extra))
|
|
{
|
|
sb.AppendLine(extra.Trim());
|
|
}
|
|
return sb.ToString().TrimEnd();
|
|
}
|
|
|
|
public List<JObject> LoadMemorySeedDocs()
|
|
{
|
|
List<JObject> docs = [];
|
|
void Scan(string root)
|
|
{
|
|
string dir = Path.Combine(root, "memory-seed");
|
|
if (!Directory.Exists(dir))
|
|
{
|
|
return;
|
|
}
|
|
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);
|
|
if (parsed is JArray arr)
|
|
{
|
|
foreach (JToken t in arr)
|
|
{
|
|
if (t is JObject jo)
|
|
{
|
|
docs.Add(jo);
|
|
}
|
|
}
|
|
}
|
|
else if (parsed is JObject single)
|
|
{
|
|
docs.Add(single);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logs.Debug($"AssistentConfig memory-seed {file}: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
Scan(Path.Combine(_bundledRoot, "_base"));
|
|
Scan(Path.Combine(_overlayRoot, "_base"));
|
|
Scan(Path.Combine(_overlayRoot, "memory-seed"));
|
|
return docs;
|
|
}
|
|
|
|
public JObject LoadSettings()
|
|
{
|
|
return TryReadJson(Path.Combine(_overlayRoot, "settings.json")) ?? new JObject();
|
|
}
|
|
|
|
public void SaveSettings(JObject settings)
|
|
{
|
|
Directory.CreateDirectory(_overlayRoot);
|
|
string path = Path.Combine(_overlayRoot, "settings.json");
|
|
File.WriteAllText(path, (settings ?? new JObject()).ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
|
|
}
|
|
|
|
public JObject LoadOllamaRoles()
|
|
{
|
|
return TryReadJson(Path.Combine(_overlayRoot, "ollama-roles.json"))
|
|
?? new JObject { ["chat"] = new JArray(), ["memory"] = new JArray() };
|
|
}
|
|
|
|
public List<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 && clientSkills.Count > 0)
|
|
{
|
|
enabled.Clear();
|
|
foreach (JToken t in clientSkills)
|
|
{
|
|
string id = SafeId(t?.ToString());
|
|
if (id is not null && catalog.Any(c => string.Equals(c.id, id, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
enabled.Add(id);
|
|
}
|
|
}
|
|
}
|
|
return catalog.Select(c => c.id).Where(enabled.Contains).ToList();
|
|
}
|
|
|
|
public JObject BuildMergedConfigPayload(string personaId)
|
|
{
|
|
string id = SafeId(personaId) ?? DefaultPersonaId();
|
|
JObject assistant = LoadAssistant(id);
|
|
JObject ui = LoadUi(id);
|
|
JObject model = LoadModelProfile(id);
|
|
JObject exact = LoadExact(id);
|
|
var packs = ListPacks(id);
|
|
var skills = ListSkills(id);
|
|
var personas = ListPersonaCatalog();
|
|
JObject identity = LoadIdentityParts(id);
|
|
return new JObject
|
|
{
|
|
["success"] = true,
|
|
["persona"] = id,
|
|
["default_persona"] = DefaultPersonaId(),
|
|
["assistant"] = assistant,
|
|
["ui"] = ui,
|
|
["model"] = model,
|
|
["exact"] = exact,
|
|
["packs"] = new JArray(packs.Select(p => new JObject
|
|
{
|
|
["id"] = p.id,
|
|
["title"] = p.title,
|
|
["order"] = p.order,
|
|
["aliases"] = new JArray(p.aliases),
|
|
})),
|
|
["skills"] = new JArray(skills.Select(s => new JObject
|
|
{
|
|
["id"] = s.id,
|
|
["title"] = s.title,
|
|
["default"] = s.defaultOn,
|
|
})),
|
|
["personas"] = new JArray(personas.Select(p => new JObject
|
|
{
|
|
["id"] = p.id,
|
|
["title"] = p.title,
|
|
["accent"] = p.accent,
|
|
["source"] = p.source,
|
|
})),
|
|
["identity"] = identity,
|
|
["identity_summary"] = RenderIdentityBlock(id),
|
|
["enabled_skills"] = new JArray(ResolveEnabledSkills(id, null)),
|
|
};
|
|
}
|
|
}
|