Per-chat Generate session with sparse deltas; drop Cards/Civitai/wanted hops; rolling history summary via the same Ollama model with a budget chip and /compress. Co-authored-by: Cursor <cursoragent@cursor.com>
1121 lines
41 KiB
C#
1121 lines
41 KiB
C#
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;
|
|
|
|
/// <summary>Prompt assembly, memory retrieval, and ask-only server hop loop (settings / inventory).</summary>
|
|
public partial class SwarmAssistentExtension
|
|
{
|
|
const int MaxToolHopsFallback = 2;
|
|
|
|
static bool IsSlimUtilityPack(string packName) =>
|
|
string.Equals(packName, "debug_explain", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(packName, "compress_history", StringComparison.OrdinalIgnoreCase);
|
|
|
|
[Obsolete("Use IsSlimUtilityPack")]
|
|
static bool IsSlimDebugPack(string packName) => IsSlimUtilityPack(packName);
|
|
|
|
(List<JObject> messages, JObject systemLayers) BuildOllamaMessages(string packName, bool includeBase, string contextJson, JArray userMessages, string extraSystem = null, string personaId = null, IEnumerable<string> skillIds = null)
|
|
{
|
|
List<JObject> 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));
|
|
}
|
|
|
|
bool slimUtility = IsSlimUtilityPack(packName);
|
|
|
|
if (Memory is not null && !slimUtility)
|
|
{
|
|
try
|
|
{
|
|
JObject asst = Config.LoadAssistant(pid);
|
|
double weight = asst["user_prefs_weight"]?.Value<double?>() ?? 1.0;
|
|
int maxPrefs = asst["user_prefs_max"]?.Value<int?>() ?? 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```");
|
|
}
|
|
|
|
if (!slimUtility)
|
|
{
|
|
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<string, Task> onDelta = null,
|
|
Func<int, Task> onHopStart = null,
|
|
string personaId = null,
|
|
JArray skillIds = null,
|
|
string embedModel = null)
|
|
{
|
|
string pid = AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId();
|
|
List<string> 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}");
|
|
}
|
|
|
|
bool slimUtility = IsSlimUtilityPack(packName);
|
|
JArray hits = [];
|
|
if (!slimUtility)
|
|
{
|
|
string retrieveQuery = BuildRetrieveQuery(userMessages, contextJson, packName);
|
|
try
|
|
{
|
|
AssistentMemory.RetrieveOptions opt = MemoryRetrieveOptions(pid);
|
|
hits = await Memory.RetrieveAsync(root, retrieveQuery, opt.TopK, embed, Config.PersonaExtendsChain(pid), opt);
|
|
hits = FilterHeardHitsIfDisabled(hits);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logs.Debug($"Assistent memory retrieve: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
string enrichedContext = InjectMemoryHits(contextJson, hits, pid);
|
|
if (!slimUtility)
|
|
{
|
|
enrichedContext = EnrichPersonaContext(enrichedContext, pid, packName);
|
|
}
|
|
(List<JObject> messages, JObject systemLayers) = BuildOllamaMessages(packName, includeBase, enrichedContext, userMessages, personaId: pid, skillIds: skills);
|
|
int systemChars = systemLayers["total"]?.Value<int?>()
|
|
?? messages.FirstOrDefault(m => string.Equals(m["role"]?.ToString(), "system", StringComparison.OrdinalIgnoreCase))?["content"]?.ToString()?.Length
|
|
?? 0;
|
|
string reply = "";
|
|
JObject lastRaw = null;
|
|
int maxHops = slimUtility ? 1 : CfgInt("max_tool_hops", MaxToolHopsFallback);
|
|
HashSet<string> hopDone = new(StringComparer.OrdinalIgnoreCase);
|
|
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);
|
|
if (slimUtility)
|
|
{
|
|
break;
|
|
}
|
|
JObject patch = TryParsePatch(reply);
|
|
// 0.14: no ApplyMemoryActions / ApplyUserPrefActions / ApplyPersonaActions / Civitai from server hop loop.
|
|
if (hop + 1 >= maxHops)
|
|
{
|
|
break;
|
|
}
|
|
string follow = null;
|
|
HashSet<string> hopSkip = new(StringComparer.OrdinalIgnoreCase);
|
|
while (true)
|
|
{
|
|
string tool = NextToolHop(patch, hopSkip);
|
|
if (string.IsNullOrWhiteSpace(tool))
|
|
{
|
|
follow = null;
|
|
break;
|
|
}
|
|
follow = await RunToolHop(session, pid, patch, tool, hopDone);
|
|
if (follow is not null)
|
|
{
|
|
break;
|
|
}
|
|
hopSkip.Add(tool);
|
|
}
|
|
if (follow is null)
|
|
{
|
|
break;
|
|
}
|
|
// 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, [], 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();
|
|
JObject agent = Config.LoadTrainingAgent();
|
|
AssistentMemory.RetrieveOptions opt = new()
|
|
{
|
|
TopK = a["memory_top_k"]?.Value<int?>() ?? 8,
|
|
MinScore = a["memory_min_score"]?.Value<float?>() ?? 0.32f,
|
|
ApplyQuotas = true,
|
|
};
|
|
Dictionary<string, int> quotas = AssistentMemory.CopyDefaultQuotas();
|
|
if (a["memory_quotas"] is JObject qOverrides)
|
|
{
|
|
foreach (JProperty p in qOverrides.Properties())
|
|
{
|
|
quotas[p.Name] = p.Value?.Value<int?>() ?? 2;
|
|
}
|
|
}
|
|
if (agent["enabled"]?.Value<bool?>() != false)
|
|
{
|
|
quotas["heard"] = agent["heard_quota"]?.Value<int?>() ?? 3;
|
|
}
|
|
else
|
|
{
|
|
quotas.Remove("heard");
|
|
}
|
|
opt.Quotas = quotas;
|
|
return opt;
|
|
}
|
|
|
|
JArray FilterHeardHitsIfDisabled(JArray hits)
|
|
{
|
|
if (Config.LoadTrainingAgent()["enabled"]?.Value<bool?>() != false)
|
|
{
|
|
return hits;
|
|
}
|
|
JArray filtered = [];
|
|
foreach (JToken t in hits ?? [])
|
|
{
|
|
if (t is JObject ho && string.Equals(ho["kind"]?.ToString(), AssistentMemory.HeardKind, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
filtered.Add(t);
|
|
}
|
|
return filtered;
|
|
}
|
|
|
|
/// <summary>Ask-only server hops: Exact/assistant settings dump or truncated inventory.</summary>
|
|
async Task<string> RunToolHop(
|
|
Session session,
|
|
string pid,
|
|
JObject patch,
|
|
string tool,
|
|
HashSet<string> hopDone)
|
|
{
|
|
if (tool == "ask_settings")
|
|
{
|
|
if (!hopDone.Add("ask_settings"))
|
|
{
|
|
return null;
|
|
}
|
|
JObject dump = BuildAskSettingsDump(pid);
|
|
return
|
|
"ask:settings dump (Exact + assistant knobs from server). "
|
|
+ "Live session fields arrive via client compact context. "
|
|
+ "Reply with a sparse delta JSON only if needed; omit ask:settings unless you need a refresh.\n```json\n"
|
|
+ dump.ToString(Newtonsoft.Json.Formatting.None) + "\n```";
|
|
}
|
|
if (tool == "ask_inventory")
|
|
{
|
|
string q = patch?["inventory_query"]?.ToString()?.Trim() ?? "";
|
|
string sig = "ask_inventory:" + q.ToLowerInvariant();
|
|
if (!hopDone.Add(sig))
|
|
{
|
|
return null;
|
|
}
|
|
int lim = Config.LoadAssistant(pid)["inventory_hop_limit"]?.Value<int?>() ?? 20;
|
|
JArray rows;
|
|
if (string.IsNullOrWhiteSpace(q))
|
|
{
|
|
JObject inv = await AssistentListInventory(session, rescan: false);
|
|
rows = TruncateInventoryForAsk(inv, lim);
|
|
}
|
|
else
|
|
{
|
|
rows = SearchInventoryForHop(q, lim);
|
|
}
|
|
return
|
|
"ask:inventory truncated LoRA/checkpoint list. Use exact names + listed triggers; "
|
|
+ "omit ask:inventory unless you need a different query.\n```json\n"
|
|
+ rows.ToString(Newtonsoft.Json.Formatting.None) + "\n```";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
static JArray TruncateInventoryForAsk(JObject inv, int limit)
|
|
{
|
|
int lim = Math.Max(1, Math.Min(limit, 40));
|
|
JArray outRows = [];
|
|
foreach (JToken t in inv?["loras"] as JArray ?? [])
|
|
{
|
|
if (outRows.Count >= lim)
|
|
{
|
|
break;
|
|
}
|
|
outRows.Add(t);
|
|
}
|
|
foreach (JToken t in inv?["checkpoints"] as JArray ?? [])
|
|
{
|
|
if (outRows.Count >= lim)
|
|
{
|
|
break;
|
|
}
|
|
outRows.Add(t);
|
|
}
|
|
return outRows;
|
|
}
|
|
|
|
JObject BuildAskSettingsDump(string pid)
|
|
{
|
|
JObject exact = Config.LoadExact(pid) ?? new JObject();
|
|
JObject asst = Config.LoadAssistant(pid) ?? new JObject();
|
|
// Knobs the model may need — not the full assistant.json blob (quotas/seed noise).
|
|
JObject knobs = new()
|
|
{
|
|
["num_ctx"] = asst["num_ctx"],
|
|
["num_predict"] = asst["num_predict"],
|
|
["max_tool_hops"] = asst["max_tool_hops"],
|
|
["inventory_hop_limit"] = asst["inventory_hop_limit"],
|
|
["max_loras_inventory"] = asst["max_loras_inventory"],
|
|
["max_checkpoints_inventory"] = asst["max_checkpoints_inventory"],
|
|
["max_gen_variants"] = asst["max_gen_variants"],
|
|
["max_ref_slots"] = asst["max_ref_slots"],
|
|
["default_pack"] = asst["default_pack"],
|
|
["default_persona"] = asst["default_persona"],
|
|
["gate"] = asst["gate"],
|
|
["context_prompt_max"] = asst["context_prompt_max"],
|
|
["history_keep_turns"] = asst["history_keep_turns"],
|
|
["compress_at"] = asst["compress_at"],
|
|
["chars_per_token"] = asst["chars_per_token"],
|
|
["compress_auto"] = asst["compress_auto"],
|
|
};
|
|
return new JObject
|
|
{
|
|
["detail"] = "settings",
|
|
["exact"] = exact,
|
|
["assistant"] = knobs,
|
|
["persona"] = pid,
|
|
};
|
|
}
|
|
|
|
/// <summary>Substring filter over current LoRA/checkpoint inventory for ask:inventory hop.</summary>
|
|
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<T2IModel> 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, string personaId = 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()) ?? personaId ?? Config?.DefaultPersonaId() ?? "neutral";
|
|
hitChars = Config?.LoadAssistant(pidHit)?["memory_hit_chars"]?.Value<int?>() ?? 240;
|
|
}
|
|
catch
|
|
{
|
|
hitChars = 240;
|
|
}
|
|
hitChars = Math.Max(80, Math.Min(hitChars, 800));
|
|
|
|
string pid = AssistentConfig.SafeId(ctx["persona"]?.ToString()) ?? personaId ?? Config?.DefaultPersonaId() ?? "neutral";
|
|
IEnumerable<string> chain = Config?.PersonaExtendsChain(pid) ?? [];
|
|
|
|
JArray clippedHits = [];
|
|
JArray heardExamples = [];
|
|
foreach (JToken t in hits ?? [])
|
|
{
|
|
if (t is not JObject ho)
|
|
{
|
|
continue;
|
|
}
|
|
if (IsExactPointerHit(ho))
|
|
{
|
|
continue;
|
|
}
|
|
if (string.Equals(ho["kind"]?.ToString(), AssistentMemory.HeardKind, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
JObject ex = Memory?.BuildHeardExampleFromHit(ho, chain);
|
|
if (ex is not null)
|
|
{
|
|
heardExamples.Add(ex);
|
|
}
|
|
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;
|
|
if (heardExamples.Count > 0)
|
|
{
|
|
ctx["heard_examples"] = heardExamples;
|
|
}
|
|
else
|
|
{
|
|
ctx.Remove("heard_examples");
|
|
}
|
|
ctx.Remove("taste_profile");
|
|
ctx.Remove("enabled_loras"); // alias of selected_loras — do not double-feed
|
|
try
|
|
{
|
|
JObject asst = Config?.LoadAssistant(pid) ?? new JObject();
|
|
double weight = asst["user_prefs_weight"]?.Value<double?>() ?? 1.0;
|
|
int maxPrefs = asst["user_prefs_max"]?.Value<int?>() ?? 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?>();
|
|
int? h = ctx["height"]?.Value<int?>();
|
|
return w is > 0 && h is > 0;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>Drop RAG rows that only point at Exact (legacy krea_facts seed / "see Exact memory…").</summary>
|
|
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<int?>() ?? 12;
|
|
namesCap = asst["inventory_prompt_names"]?.Value<int?>() ?? 24;
|
|
}
|
|
catch { /* defaults */ }
|
|
richCap = Math.Max(4, Math.Min(richCap, 40));
|
|
namesCap = Math.Max(richCap, Math.Min(namesCap, 80));
|
|
|
|
HashSet<string> 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<JToken> ordered = allLoras
|
|
.OrderByDescending(t => keepRich.Contains(t?["name"]?.ToString() ?? "") ? 1000 : 0)
|
|
.ThenByDescending(t => t?["krea_likely"]?.Value<bool>() == 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<bool>() == 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<bool>() == 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Ensure rich LoRA rows have triggers/blurb from Swarm inventory when the client sent name-only.</summary>
|
|
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<bool>() == true)
|
|
{
|
|
outRow["krea_likely"] = 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<string> 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;
|
|
}
|
|
|
|
/// <summary>Apply overlay persona clone/write from patch. Ignores persona_delete. Updates pid ref after switch.</summary>
|
|
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<bool?>() == 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). Generate patches never touch sliders.
|
|
if (patch["controls"] is JObject ctrlVals)
|
|
{
|
|
if (AssistentConfig.PatchLooksLikeGeneration(patch))
|
|
{
|
|
patch.Remove("controls");
|
|
}
|
|
else
|
|
{
|
|
string ctrlPid = AssistentConfig.SafeId(patch["persona"]?.ToString()) ?? personaId;
|
|
JObject schema = Config.LoadControlsSchema(ctrlPid);
|
|
JObject current = Config.LoadControlValues(ctrlPid);
|
|
JObject filtered = AssistentConfig.FilterEchoedControlDefaults(
|
|
schema, current, ctrlVals, patchLooksLikeGen: false);
|
|
if (filtered.Count > 0)
|
|
{
|
|
Config.SaveControlValues(ctrlPid, filtered);
|
|
patch["controls"] = filtered;
|
|
patch["_controls_saved"] = true;
|
|
}
|
|
else
|
|
{
|
|
patch.Remove("controls");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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<bool>() == 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}");
|
|
}
|
|
}
|
|
}
|
|
}
|