using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Threading.Tasks; using Newtonsoft.Json.Linq; using SwarmUI.Accounts; using SwarmUI.Core; using SwarmUI.Text2Image; using SwarmUI.Utils; namespace Mrleo1nid.SwarmAssistent; /// Prompt assembly, memory retrieval/writeback and the Civitai search hop loop. public partial class SwarmAssistentExtension { const int MaxCivitaiHopsFallback = 2; const int MaxToolHopsFallback = 4; (List messages, JObject systemLayers) BuildOllamaMessages(string packName, bool includeBase, string contextJson, JArray userMessages, string extraSystem = null, string personaId = null, IEnumerable skillIds = null) { List ollamaMessages = []; StringBuilder system = new(); JObject layers = new(); string pid = AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId(); void AddLayer(string name, string block) { if (string.IsNullOrWhiteSpace(block)) { return; } if (system.Length > 0) { system.AppendLine(); } int before = system.Length; system.AppendLine(block.TrimEnd()); layers[name] = system.Length - before; } if (includeBase) { AddLayer("core", Config.LoadCorePrompt(pid)); } if (Memory is not null) { try { JObject asst = Config.LoadAssistant(pid); double weight = asst["user_prefs_weight"]?.Value() ?? 1.0; int maxPrefs = asst["user_prefs_max"]?.Value() ?? 16; AddLayer("prefs", Memory.FormatUserPrefsBlock(pid, weight, maxPrefs)); } catch (Exception ex) { Logs.Debug($"BuildOllamaMessages user prefs: {ex.Message}"); } } JObject exact = Config.LoadExactForPrompt(pid); if (exact is not null && exact.Count > 0 && LiveContextHasSize(contextJson)) { exact.Remove("aspect_table"); } if (exact is not null && exact.Count > 0) { AddLayer("exact", "## Exact memory (canonical KV defaults — prefer over RAG for numbers)\n```json\n" + exact.ToString(Newtonsoft.Json.Formatting.None) + "\n```"); } StringBuilder skillsBlock = new(); foreach (string skillId in skillIds ?? Config.ResolveEnabledSkills(pid, null)) { string skillText = Config.LoadSkillPrompt(pid, skillId); if (!string.IsNullOrWhiteSpace(skillText)) { if (skillsBlock.Length > 0) { skillsBlock.AppendLine(); } skillsBlock.AppendLine($"## Skill: {skillId}"); skillsBlock.AppendLine(skillText.TrimEnd()); } } AddLayer("skills", skillsBlock.ToString()); AddLayer("identity", Config.RenderIdentityBlock(pid)); if (!string.IsNullOrWhiteSpace(packName)) { string situational = Config.LoadPackPrompt(pid, packName); if (!string.IsNullOrWhiteSpace(situational)) { AddLayer("pack", $"## Active mode: {packName}\n{situational.TrimEnd()}"); } } if (!string.IsNullOrWhiteSpace(contextJson)) { AddLayer("live", "## Live SwarmUI context (JSON — trust this over guesses)\n```json\n" + contextJson + "\n```"); } if (!string.IsNullOrWhiteSpace(extraSystem)) { AddLayer("extra", extraSystem); } layers["total"] = system.Length; if (system.Length > 0) { ollamaMessages.Add(new JObject { ["role"] = "system", ["content"] = system.ToString(), }); } foreach (JToken msg in userMessages ?? []) { if (msg is not JObject mo) { continue; } JObject copy = new() { ["role"] = mo["role"]?.ToString() ?? "user", ["content"] = mo["content"]?.ToString() ?? "", }; if (mo["images"] is JArray images && images.Count > 0) { copy["images"] = images; } ollamaMessages.Add(copy); } return (ollamaMessages, layers); } async Task<(string reply, JObject raw, JArray civitaiResults, int systemChars, JObject systemLayers)> RunChatWithHops( Session session, string root, string modelName, string packName, bool includeBase, string contextJson, JArray userMessages, Func onDelta = null, Func onHopStart = null, string personaId = null, JArray skillIds = null, string embedModel = null) { string pid = AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId(); List skills = Config.ResolveEnabledSkills(pid, skillIds); string embed = string.IsNullOrWhiteSpace(embedModel) ? (Config.LoadSettings()["embed_model"]?.ToString() ?? Config.LoadAssistant(pid)["embed_model"]?.ToString() ?? "nomic-embed-text") : embedModel; try { await Memory.EnsureSeedAsync(root, Config, embed); } catch (Exception ex) { Logs.Debug($"Assistent memory seed: {ex.Message}"); } string retrieveQuery = BuildRetrieveQuery(userMessages, contextJson, packName); JArray hits = []; try { AssistentMemory.RetrieveOptions opt = MemoryRetrieveOptions(pid); hits = await Memory.RetrieveAsync(root, retrieveQuery, opt.TopK, embed, Config.PersonaExtendsChain(pid), opt); } catch (Exception ex) { Logs.Debug($"Assistent memory retrieve: {ex.Message}"); } string enrichedContext = InjectMemoryHits(contextJson, hits); enrichedContext = EnrichPersonaContext(enrichedContext, pid, packName); (List messages, JObject systemLayers) = BuildOllamaMessages(packName, includeBase, enrichedContext, userMessages, personaId: pid, skillIds: skills); int systemChars = systemLayers["total"]?.Value() ?? messages.FirstOrDefault(m => string.Equals(m["role"]?.ToString(), "system", StringComparison.OrdinalIgnoreCase))?["content"]?.ToString()?.Length ?? 0; JArray civitaiResults = []; string reply = ""; JObject lastRaw = null; int maxHops = Math.Max(CfgInt("max_civitai_hops", MaxCivitaiHopsFallback), CfgInt("max_tool_hops", MaxToolHopsFallback)); HashSet hopDone = new(StringComparer.OrdinalIgnoreCase); var chain = Config.PersonaExtendsChain(pid); for (int hop = 0; hop < maxHops; hop++) { if (onHopStart is not null) { await onHopStart(hop); } (reply, lastRaw) = await CallOllamaChat(root, modelName, messages, stream: onDelta is not null, onDelta, pid); JObject patch = TryParsePatch(reply); await ApplyMemoryActions(root, patch, embed, pid); ApplyUserPrefActions(patch, pid); ApplyPersonaActions(patch, ref pid); if (hop + 1 >= maxHops) { break; } string tool = NextToolHop(patch); if (string.IsNullOrWhiteSpace(tool)) { break; } (string follow, JArray civitaiHop) = await RunToolHop(session, root, embed, pid, chain, patch, tool, hopDone); if (follow is null) { break; } if (civitaiHop is { Count: > 0 }) { civitaiResults = civitaiHop; } // Re-feed only the parsed patch JSON (not full prose) to save hop tokens. string assistantContent = patch is not null ? patch.ToString(Newtonsoft.Json.Formatting.None) : reply; messages.Add(new JObject { ["role"] = "assistant", ["content"] = assistantContent }); messages.Add(new JObject { ["role"] = "user", ["content"] = follow }); } return (reply, lastRaw, civitaiResults, systemChars, systemLayers); } static string BuildRetrieveQuery(JArray userMessages, string contextJson, string packName = null) { StringBuilder sb = new(); if (!string.IsNullOrWhiteSpace(packName)) { sb.Append(packName).Append(' '); } if (!string.IsNullOrWhiteSpace(contextJson)) { try { JObject ctx = JObject.Parse(contextJson); string ckpt = ctx["checkpoint"]?.ToString() ?? ctx["current_model"]?.ToString(); if (!string.IsNullOrWhiteSpace(ckpt)) { sb.Append(ckpt).Append(' '); } if (ctx["selected_loras"] is JArray selLoras) { foreach (JToken t in selLoras.Take(12)) { string n = t?["name"]?.ToString() ?? t?.ToString(); if (!string.IsNullOrWhiteSpace(n)) { sb.Append(n).Append(' '); } } } else if (ctx["enabled_loras"] is JArray en) { foreach (JToken t in en.Take(12)) { string n = t?["name"]?.ToString() ?? t?.ToString(); if (!string.IsNullOrWhiteSpace(n)) { sb.Append(n).Append(' '); } } } if (ctx["krea_profile"] != null) { sb.Append("krea ").Append(ctx["krea_profile"]).Append(' '); } string aspect = ctx["aspect"]?.ToString(); if (!string.IsNullOrWhiteSpace(aspect)) { sb.Append(aspect).Append(' '); } string prompt = ctx["prompt"]?.ToString(); if (!string.IsNullOrWhiteSpace(prompt)) { sb.Append(prompt.Length > 400 ? prompt[..400] : prompt).Append(' '); } } catch { // ignore } } foreach (JToken msg in (userMessages ?? []).Reverse().Take(3)) { if (msg is JObject mo && string.Equals(mo["role"]?.ToString(), "user", StringComparison.OrdinalIgnoreCase)) { string c = mo["content"]?.ToString() ?? ""; sb.Append(c.Length > 500 ? c[..500] : c).Append(' '); } } string q = CollapseWs(sb.ToString()); return string.IsNullOrWhiteSpace(q) ? "krea2 prompting" : q; } AssistentMemory.RetrieveOptions MemoryRetrieveOptions(string pid) { JObject a = Config.LoadAssistant(pid) ?? new JObject(); AssistentMemory.RetrieveOptions opt = new() { TopK = a["memory_top_k"]?.Value() ?? 8, MinScore = a["memory_min_score"]?.Value() ?? 0.32f, ApplyQuotas = true, }; if (a["memory_quotas"] is JObject quotas) { Dictionary d = new(StringComparer.OrdinalIgnoreCase); foreach (JProperty p in quotas.Properties()) { d[p.Name] = p.Value?.Value() ?? 2; } opt.Quotas = d; } return opt; } async Task<(string follow, JArray civitai)> RunToolHop( Session session, string root, string embed, string pid, IEnumerable chain, JObject patch, string tool, HashSet hopDone) { if (tool == "memory_get") { JArray got = []; foreach (JToken t in patch["memories"] as JArray ?? []) { if (t is not JObject mo) { continue; } string kind = mo["kind"]?.ToString() ?? "note"; string key = mo["key"]?.ToString() ?? ""; if (string.IsNullOrWhiteSpace(key)) { continue; } string sig = $"get:{kind}:{key}"; if (!hopDone.Add(sig)) { continue; } JObject row = Memory.Get(kind, key, chain); got.Add(row ?? new JObject { ["kind"] = kind, ["key"] = key, ["missing"] = true }); } if (got.Count == 0) { return (null, null); } return ( "memory_get results (JSON). Use these facts; omit memory_get unless you need a different key.\n```json\n" + got.ToString(Newtonsoft.Json.Formatting.None) + "\n```", null); } if (tool == "memory_search") { string q = ExtractMemoryQuery(patch); if (string.IsNullOrWhiteSpace(q) || !hopDone.Add("search:" + q)) { return (null, null); } string kind = patch["memory_kind"]?.ToString(); int topK = Config.LoadAssistant(pid)["memory_top_k"]?.Value() ?? 8; JArray rows = await Memory.SearchAsync(root, q, kind, topK, embed, chain); return ( "memory_search results (JSON, hybrid FTS+vector). Omit memory_search unless you need a different query.\n```json\n" + rows.ToString(Newtonsoft.Json.Formatting.None) + "\n```", null); } if (tool == "lookup_tags") { string q = ExtractTagQuery(patch); if (string.IsNullOrWhiteSpace(q) || !hopDone.Add("tags:" + q)) { return (null, null); } int lim = Config.LoadAssistant(pid)["tag_lookup_limit"]?.Value() ?? 20; JArray tags = Memory.LookupTags(q, lim); return ( "lookup_tags results from Danbooru csv (canonical name, aliases, post_count). Krea prompts stay natural prose — use this to check spelling/aliases, do not dump tag soup.\n```json\n" + tags.ToString(Newtonsoft.Json.Formatting.None) + "\n```", null); } if (tool == "list_inventory") { string q = patch["inventory_query"]?.ToString()?.Trim() ?? ""; string sig = "inv:" + q.ToLowerInvariant(); if (!hopDone.Add(sig)) { return (null, null); } int lim = Config.LoadAssistant(pid)["inventory_hop_limit"]?.Value() ?? 20; JArray rows = SearchInventoryForHop(q, lim); return ( "list_inventory results (rich LoRA/checkpoint rows). Use exact names + listed triggers; omit list_inventory unless you need a different query.\n```json\n" + rows.ToString(Newtonsoft.Json.Formatting.None) + "\n```", null); } if (tool == "skill_load") { List ids = []; if (patch["skills"] is JArray skArr) { foreach (JToken t in skArr) { string sid = AssistentConfig.SafeId(t?.ToString()); if (!string.IsNullOrWhiteSpace(sid)) { ids.Add(sid); } } } if (ids.Count == 0) { ids.Add("memory"); } StringBuilder sb = new(); foreach (string sid in ids) { string sig = "skill:" + sid; if (!hopDone.Add(sig)) { continue; } string text = Config.LoadSkillPrompt(pid, sid); if (string.IsNullOrWhiteSpace(text)) { continue; } sb.AppendLine($"## Skill: {sid}"); sb.AppendLine(text); sb.AppendLine(); } if (sb.Length == 0) { return (null, null); } return ( "skill_load results. Follow these skill rules on the next reply; omit skill_load unless you need another skill.\n\n" + sb.ToString().TrimEnd(), null); } if (tool == "persona_read") { List shelves = []; if (patch["persona_shelves"] is JArray shArr) { foreach (JToken t in shArr) { string name = t?.ToString()?.Trim(); if (!string.IsNullOrWhiteSpace(name)) { shelves.Add(name); } } } else if (patch["persona_shelves"] is JObject shObj) { // Model sometimes echoes shelf objects; treat keys as names. foreach (JProperty p in shObj.Properties()) { shelves.Add(p.Name); } } string sig = "persona_read:" + string.Join(",", shelves); if (!hopDone.Add(sig)) { return (null, null); } string body = Config.RenderPersonaReadBlock(pid, shelves.Count > 0 ? shelves : null); if (string.IsNullOrWhiteSpace(body)) { return ("persona_read: no additional lore shelves for this persona.", null); } return ( "persona_read results (lore shelves). Use for roleplay/appearance/outfit detail; omit persona_read unless you need different shelves.\n\n" + body, null); } if (tool == "civitai") { string query = ExtractSearchQuery(patch); if (string.IsNullOrWhiteSpace(query)) { if (!hopDone.Add("civitai:missing_query")) { return (null, null); } return ( "search_civitai skipped: provide a short search_query (LoRA keywords only). " + "Never search with the whole user message. Then retry with actions:[\"search_civitai\"] + search_query, " + "or continue using available_loras only.", null); } if (!hopDone.Add("civitai:" + query)) { return (null, null); } JObject search = await AssistentSearchCivitai(session, query, 8); if (search["error"] is not null) { return ($"Civitai search failed: {search["error"]}. Continue without download — use only available_loras from context.", null); } JArray civitaiResults = search["results"] as JArray ?? []; return ( "Civitai search results (JSON). Prefer `krea_likely: true`. Do NOT download yourself — the UI shows Confirm cards. " + "Pick useful LoRAs from results or available_loras, emit a normal patch (prompt/loras). " + "Omit search_civitai from actions unless you need a different query.\n```json\n" + civitaiResults.ToString(Newtonsoft.Json.Formatting.None) + "\n```", civitaiResults); } return (null, null); } /// Substring filter over current LoRA/checkpoint inventory for list_inventory hop. JArray SearchInventoryForHop(string query, int limit) { int lim = Math.Max(1, Math.Min(limit, 40)); string q = (query ?? "").Trim().ToLowerInvariant(); JArray outRows = []; void AddFromHandler(string setName, string kind) { if (!Program.T2IModelSets.TryGetValue(setName, out T2IModelHandler handler)) { return; } IEnumerable models = handler.Models.Values .OrderByDescending(LooksLikeKreaArch) .ThenBy(m => m.Name); foreach (T2IModel model in models) { if (outRows.Count >= lim) { return; } string name = model.Name ?? ""; if (!string.IsNullOrEmpty(q)) { string blob = $"{name} {model.Metadata?.UsageHint} {model.Metadata?.Description}".ToLowerInvariant(); if (!blob.Contains(q, StringComparison.Ordinal)) { continue; } } outRows.Add(BuildInventoryModelEntry(model, kind)); } } AddFromHandler("LoRA", "lora"); if (outRows.Count < lim) { AddFromHandler("Stable-Diffusion", "checkpoint"); } return outRows; } string InjectMemoryHits(string contextJson, JArray hits, JObject exact = null) { JObject ctx; try { ctx = string.IsNullOrWhiteSpace(contextJson) ? new JObject() : JObject.Parse(contextJson); } catch { ctx = new JObject { ["_raw_context"] = contextJson }; } int hitChars = 240; try { string pidHit = AssistentConfig.SafeId(ctx["persona"]?.ToString()) ?? Config?.DefaultPersonaId() ?? "neutral"; hitChars = Config?.LoadAssistant(pidHit)?["memory_hit_chars"]?.Value() ?? 240; } catch { hitChars = 240; } hitChars = Math.Max(80, Math.Min(hitChars, 800)); JArray clippedHits = []; foreach (JToken t in hits ?? []) { if (t is not JObject ho) { continue; } if (IsExactPointerHit(ho)) { continue; } JObject copy = (JObject)ho.DeepClone(); string text = copy["text"]?.ToString() ?? ""; if (text.Length > hitChars) { copy["text"] = text[..hitChars] + "…"; copy["truncated"] = true; } clippedHits.Add(copy); } ctx["memory_hits"] = clippedHits; ctx.Remove("taste_profile"); ctx.Remove("enabled_loras"); // alias of selected_loras — do not double-feed try { string pid = AssistentConfig.SafeId(ctx["persona"]?.ToString()) ?? Config?.DefaultPersonaId() ?? "neutral"; JObject asst = Config?.LoadAssistant(pid) ?? new JObject(); double weight = asst["user_prefs_weight"]?.Value() ?? 1.0; int maxPrefs = asst["user_prefs_max"]?.Value() ?? 16; ctx["user_prefs_count"] = Memory?.SelectUserPrefsForPrompt(pid, weight, maxPrefs).Count ?? 0; } catch { ctx["user_prefs_count"] = 0; } // Never re-inject full Exact into live context (already in system prompt). ctx.Remove("exact"); if (ctx["session_exact"] is JObject se && !se.Properties().Any()) { ctx.Remove("session_exact"); } // Always normalize inventory: keep enabled + rich top-N; name-only for the rest. SlimAvailableLorasInContext(ctx, hits); DropNullOrEmpty(ctx); return ctx.ToString(Newtonsoft.Json.Formatting.None); } static bool LiveContextHasSize(string contextJson) { if (string.IsNullOrWhiteSpace(contextJson)) { return false; } try { JObject ctx = JObject.Parse(contextJson); int? w = ctx["width"]?.Value(); int? h = ctx["height"]?.Value(); return w is > 0 && h is > 0; } catch { return false; } } /// Drop RAG rows that only point at Exact (legacy krea_facts seed / "see Exact memory…"). static bool IsExactPointerHit(JObject ho) { if (ho is null) { return false; } string key = (ho["key"]?.ToString() ?? "").Trim().ToLowerInvariant(); if (key.StartsWith("krea2_", StringComparison.Ordinal)) { return true; } string text = (ho["text"]?.ToString() ?? "").ToLowerInvariant(); if (string.IsNullOrWhiteSpace(text)) { return false; } return text.Contains("live in exact memory", StringComparison.Ordinal) || text.Contains("see exact memory", StringComparison.Ordinal) || text.Contains("prefer exact kv", StringComparison.Ordinal) || text.Contains("exact.facts.", StringComparison.Ordinal) || text.Contains("exact memory profiles.", StringComparison.Ordinal); } void SlimAvailableLorasInContext(JObject ctx, JArray hits) { if (ctx["available_loras"] is not JArray allLoras || allLoras.Count == 0) { return; } int richCap = 12; int namesCap = 24; try { string pid = AssistentConfig.SafeId(ctx["persona"]?.ToString()) ?? Config?.DefaultPersonaId() ?? "neutral"; JObject asst = Config?.LoadAssistant(pid) ?? new JObject(); richCap = asst["inventory_prompt_rich"]?.Value() ?? 12; namesCap = asst["inventory_prompt_names"]?.Value() ?? 24; } catch { /* defaults */ } richCap = Math.Max(4, Math.Min(richCap, 40)); namesCap = Math.Max(richCap, Math.Min(namesCap, 80)); HashSet keepRich = new(StringComparer.OrdinalIgnoreCase); if (ctx["enabled_loras"] is JArray en) { foreach (JToken t in en) { string n = t?["name"]?.ToString() ?? t?.ToString(); if (!string.IsNullOrWhiteSpace(n)) { keepRich.Add(n); } } } if (ctx["selected_loras"] is JArray sel) { foreach (JToken t in sel) { string n = t?["name"]?.ToString() ?? t?.ToString(); if (!string.IsNullOrWhiteSpace(n)) { keepRich.Add(n); } } } foreach (JToken hit in hits ?? []) { if (string.Equals(hit?["kind"]?.ToString(), "lora", StringComparison.OrdinalIgnoreCase) || string.Equals(hit?["kind"]?.ToString(), "card", StringComparison.OrdinalIgnoreCase)) { string k = hit?["key"]?.ToString(); if (!string.IsNullOrWhiteSpace(k)) { keepRich.Add(k); } } } List ordered = allLoras .OrderByDescending(t => keepRich.Contains(t?["name"]?.ToString() ?? "") ? 1000 : 0) .ThenByDescending(t => t?["krea_likely"]?.Value() == true ? 50 : 0) .ThenBy(t => t?["name"]?.ToString() ?? "", StringComparer.OrdinalIgnoreCase) .ToList(); JArray slim = []; int richCount = 0; foreach (JToken t in ordered) { if (slim.Count >= namesCap) { break; } string n = t?["name"]?.ToString(); if (string.IsNullOrWhiteSpace(n)) { continue; } bool wantRich = keepRich.Contains(n) || (t?["krea_likely"]?.Value() == true && richCount < richCap); if (wantRich) { JObject rich = EnrichLoraRowForPrompt(t as JObject ?? new JObject { ["name"] = n }); slim.Add(rich); if (!keepRich.Contains(n)) { richCount++; } } else { JObject nameOnly = new() { ["name"] = n }; if (t?["krea_likely"]?.Value() == true) { nameOnly["krea_likely"] = true; } slim.Add(nameOnly); } } ctx["available_loras"] = slim; if (allLoras.Count > slim.Count) { ctx["available_loras_truncated"] = true; // Prefer client total if already set (full disk inventory count). if (ctx["available_loras_total"] is null) { ctx["available_loras_total"] = allLoras.Count; } } } /// Ensure rich LoRA rows have triggers/blurb from Swarm inventory when the client sent name-only. JObject EnrichLoraRowForPrompt(JObject row) { if (row is null) { return new JObject(); } JObject outRow = (JObject)row.DeepClone(); string name = outRow["name"]?.ToString(); bool needsTriggers = string.IsNullOrWhiteSpace(outRow["trigger_phrase"]?.ToString()) && (outRow["triggers"] is not JArray tr || tr.Count == 0); bool needsBlurb = string.IsNullOrWhiteSpace(outRow["blurb"]?.ToString()); if (!needsTriggers && !needsBlurb) { return outRow; } JObject fromDisk = FindInventoryLoraByName(name); if (fromDisk is null) { return outRow; } if (needsTriggers) { if (!string.IsNullOrWhiteSpace(fromDisk["trigger_phrase"]?.ToString())) { outRow["trigger_phrase"] = fromDisk["trigger_phrase"]; } if (fromDisk["triggers"] is JArray ft && ft.Count > 0) { outRow["triggers"] = ft.DeepClone(); } } if (needsBlurb && !string.IsNullOrWhiteSpace(fromDisk["blurb"]?.ToString())) { outRow["blurb"] = fromDisk["blurb"]; } if (outRow["default_weight"] is null && fromDisk["default_weight"] is not null) { outRow["default_weight"] = fromDisk["default_weight"]; } if (fromDisk["krea_likely"]?.Value() == true) { outRow["krea_likely"] = true; } if (fromDisk["has_card"]?.Value() == true) { outRow["has_card"] = true; } return outRow; } JObject FindInventoryLoraByName(string name) { if (string.IsNullOrWhiteSpace(name) || !Program.T2IModelSets.TryGetValue("LoRA", out T2IModelHandler handler)) { return null; } T2IModel model = handler.Models.Values.FirstOrDefault(m => string.Equals(m.Name, name, StringComparison.OrdinalIgnoreCase) || string.Equals(Path.GetFileNameWithoutExtension(m.Name), Path.GetFileNameWithoutExtension(name), StringComparison.OrdinalIgnoreCase) || (m.Name?.EndsWith("/" + name, StringComparison.OrdinalIgnoreCase) ?? false)); return model is null ? null : BuildInventoryModelEntry(model, "lora"); } static void DropNullOrEmpty(JObject ctx) { List remove = []; foreach (JProperty p in ctx.Properties()) { if (p.Value is null || p.Value.Type == JTokenType.Null) { remove.Add(p.Name); } else if (p.Value is JObject jo && !jo.Properties().Any()) { remove.Add(p.Name); } else if (p.Value is JArray ja && ja.Count == 0 && p.Name is not "memory_hits") { remove.Add(p.Name); } } foreach (string k in remove) { ctx.Remove(k); } } string EnrichPersonaContext(string contextJson, string personaId, string packName) { JObject ctx; try { ctx = string.IsNullOrWhiteSpace(contextJson) ? new JObject() : JObject.Parse(contextJson); } catch { ctx = new JObject { ["_raw_context"] = contextJson }; } string pid = AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId(); ctx["persona_source"] = Config.PersonaSource(pid); JObject schema = Config.LoadControlsSchema(pid); JObject values = Config.LoadControlValues(pid); bool authorPack = string.Equals(packName, "author_persona", StringComparison.OrdinalIgnoreCase) || string.Equals(packName, "persona", StringComparison.OrdinalIgnoreCase); // Values only outside author pack (schema is fat). Author pack gets full schema. if (values.Properties().Any()) { if (authorPack && schema.Properties().Any()) { ctx["persona_controls"] = new JObject { ["schema"] = schema, ["values"] = values, }; } else { ctx["persona_controls"] = new JObject { ["values"] = values }; } } JArray catalog = []; if (authorPack) { foreach (var p in Config.ListPersonaCatalog()) { catalog.Add(new JObject { ["id"] = p.id, ["title"] = p.title, }); } ctx["personas"] = catalog; JObject shelves = Config.LoadIdentityParts(pid); shelves.Remove("extra"); ctx["persona_shelves"] = shelves; if (schema.Properties().Any()) { ctx["persona_controls_schema"] = schema; } } DropNullOrEmpty(ctx); return ctx.ToString(Newtonsoft.Json.Formatting.None); } static string MemoryWritePersona(JObject mo, string currentPersonaId) { string scope = (mo?["scope"]?.ToString() ?? "").Trim().ToLowerInvariant(); if (scope is "shared" or "common" or "global") { return AssistentMemory.SharedPersona; } // Personal only — never let the model write into another personality's store. return AssistentConfig.SafeId(currentPersonaId) ?? AssistentMemory.SharedPersona; } /// Apply overlay persona clone/write from patch. Ignores persona_delete. Updates pid ref after switch. void ApplyPersonaActions(JObject patch, ref string personaId) { if (patch is null || Config is null) { return; } // Never honor delete from the model. bool wantClone = false, wantWrite = false; if (patch["actions"] is JArray acts) { foreach (JToken a in acts) { string s = a?.ToString() ?? ""; if (string.Equals(s, "persona_clone", StringComparison.OrdinalIgnoreCase)) { wantClone = true; } if (string.Equals(s, "persona_write", StringComparison.OrdinalIgnoreCase)) { wantWrite = true; } } } if (patch["persona_clone"] is JObject) { wantClone = true; } // persona_shelves as object of content = write; as array of names = persona_read hop (ignore here). if (patch["persona_shelves"] is JObject && !ActionsContain(patch, "persona_read")) { wantWrite = true; } try { if (wantClone && patch["persona_clone"] is JObject clone) { string from = AssistentConfig.SafeId(clone["from"]?.ToString()) ?? personaId; string to = AssistentConfig.SafeId(clone["to"]?.ToString()); string title = clone["title"]?.ToString(); bool overwrite = clone["overwrite"]?.Value() == true; if (to is not null) { Config.ClonePersonaToOverlay(from, to, title, overwrite); personaId = to; patch["_persona_cloned"] = to; } } if (wantWrite && patch["persona_shelves"] is JObject shelves) { string target = AssistentConfig.SafeId(patch["persona"]?.ToString()) ?? AssistentConfig.SafeId(patch["persona_clone"]?["to"]?.ToString()) ?? personaId; if (target is not null) { Config.SavePersonaShelves(target, shelves); patch["_persona_written"] = target; } } // Control values from model patch (Exact). if (patch["controls"] is JObject ctrlVals) { string ctrlPid = AssistentConfig.SafeId(patch["persona"]?.ToString()) ?? personaId; Config.SaveControlValues(ctrlPid, ctrlVals); patch["_controls_saved"] = true; } } catch (Exception ex) { Logs.Warning($"Assistent persona actions: {ex.Message}"); patch["_persona_error"] = ex.Message; } } async Task ApplyMemoryActions(string root, JObject patch, string embedModel, string personaId) { if (patch is null || Memory is null) { return; } bool upsert = false, forget = false; if (patch["actions"] is JArray acts) { foreach (JToken a in acts) { string s = a?.ToString() ?? ""; if (string.Equals(s, "memory_upsert", StringComparison.OrdinalIgnoreCase)) { upsert = true; } if (string.Equals(s, "memory_forget", StringComparison.OrdinalIgnoreCase)) { forget = true; } } } JArray memories = patch["memories"] as JArray; if (memories is null || memories.Count == 0) { return; } foreach (JToken t in memories) { if (t is not JObject mo) { continue; } string kind = mo["kind"]?.ToString() ?? "note"; string key = mo["key"]?.ToString() ?? ""; string text = mo["text"]?.ToString() ?? ""; string target = MemoryWritePersona(mo, personaId); try { if (forget && string.IsNullOrWhiteSpace(text)) { Memory.Forget(kind, key, persona: target); } else if (upsert || !string.IsNullOrWhiteSpace(text)) { await Memory.UpsertTextAsync(root, kind, key, text, "user", mo, embedModel, target); } } catch (Exception ex) { Logs.Debug($"ApplyMemoryActions: {ex.Message}"); } } } void ApplyUserPrefActions(JObject patch, string personaId) { if (patch is null || Memory is null) { return; } bool upsert = false, forget = false; if (patch["actions"] is JArray acts) { foreach (JToken a in acts) { string s = a?.ToString() ?? ""; if (string.Equals(s, "user_pref_upsert", StringComparison.OrdinalIgnoreCase)) { upsert = true; } if (string.Equals(s, "user_pref_forget", StringComparison.OrdinalIgnoreCase)) { forget = true; } } } JArray prefs = patch["user_prefs"] as JArray; if (prefs is null || prefs.Count == 0) { return; } string pid = AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId(); foreach (JToken t in prefs) { if (t is not JObject mo) { continue; } string key = mo["key"]?.ToString() ?? ""; string text = mo["text"]?.ToString() ?? ""; string scope = mo["scope"]?.ToString() ?? "global"; bool pinned = mo["pinned"]?.Value() == true; try { if (forget && string.IsNullOrWhiteSpace(text)) { Memory.ForgetUserPref(key, scope, pid); } else if (upsert || !string.IsNullOrWhiteSpace(text)) { Memory.UpsertUserPref(key, text, scope, pid, "agent", pinned); } } catch (Exception ex) { Logs.Debug($"ApplyUserPrefActions: {ex.Message}"); } } } }