using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using Newtonsoft.Json.Linq;
namespace Mrleo1nid.SwarmAssistent;
/// Parsing and normalization of the JSON patch the model emits inside fenced code blocks.
public partial class SwarmAssistentExtension
{
static readonly Regex JsonFenceRe = new(@"```(?:json)?\s*([\s\S]*?)```", RegexOptions.IgnoreCase | RegexOptions.Compiled);
static string[] PatchKeys => _patchKeys ??= Config?.LoadPatchKeys() ?? [];
static string[] _patchKeys;
static bool HasValue(JObject obj, string key)
{
JToken token = obj?[key];
return token is not null && token.Type != JTokenType.Null;
}
/// Maps legacy/alias patch fields onto their canonical names. Aliases are kept so older consumers still work.
public static JObject NormalizePatch(JObject patch)
{
if (patch is null)
{
return null;
}
if (!HasValue(patch, "search_query") && HasValue(patch, "civitai_query"))
{
patch["search_query"] = patch["civitai_query"];
}
if (!HasValue(patch, "init_creativity") && HasValue(patch, "denoise"))
{
patch["init_creativity"] = patch["denoise"];
}
if (!HasValue(patch, "look_at"))
{
if (HasValue(patch, "vision_from"))
{
patch["look_at"] = patch["vision_from"];
}
else if (HasValue(patch, "vision_slots"))
{
patch["look_at"] = patch["vision_slots"];
}
}
return patch;
}
static bool LooksLikeCardObject(JObject obj)
{
if (obj is null)
{
return false;
}
bool cardish = HasValue(obj, "kind") || HasValue(obj, "triggers") || HasValue(obj, "when") || HasValue(obj, "prompt_hint");
bool genish = HasValue(obj, "prompt") || HasValue(obj, "negative") || HasValue(obj, "loras") || HasValue(obj, "actions")
|| HasValue(obj, "width") || HasValue(obj, "height") || HasValue(obj, "steps") || HasValue(obj, "cfg")
|| HasValue(obj, "aspect") || HasValue(obj, "seed") || HasValue(obj, "search_query")
|| HasValue(obj, "civitai_query") || HasValue(obj, "look_at") || HasValue(obj, "controls");
if (cardish && !genish && (HasValue(obj, "name") || HasValue(obj, "triggers") || HasValue(obj, "when")))
{
return true;
}
return HasValue(obj, "kind") && HasValue(obj, "name")
&& (HasValue(obj, "triggers") || HasValue(obj, "when") || HasValue(obj, "prompt_hint") || HasValue(obj, "notes"));
}
static JObject TryParsePatch(string reply)
{
if (string.IsNullOrWhiteSpace(reply))
{
return null;
}
JObject lastAny = null;
JObject lastTerminal = null;
foreach (Match match in JsonFenceRe.Matches(reply))
{
string raw = match.Groups[1].Value.Trim();
try
{
JObject obj = JObject.Parse(raw);
if (obj is null || LooksLikeCardObject(obj))
{
continue;
}
if (Array.Exists(PatchKeys, k => obj[k] is not null))
{
JObject normalized = NormalizePatch(obj);
lastAny = normalized;
if (FenceIsTerminalPatch(obj))
{
lastTerminal = normalized;
}
}
}
catch
{
// not json
}
}
return lastTerminal ?? lastAny;
}
///
/// If the reply already contains a closed fenced patch/card that is "done enough" to act on,
/// cut everything after it. Do NOT stop on weak fences (pack/creativity/notes-only) — models
/// often emit a tiny JSON first then the real prompt fence; aborting early cuts the prompt
/// and blocks skill_load / generate.
///
static bool TryTruncateAtCompleteFence(string reply, out string truncated)
{
truncated = reply ?? "";
if (string.IsNullOrWhiteSpace(reply))
{
return false;
}
MatchCollection matches = JsonFenceRe.Matches(reply);
if (matches.Count == 0)
{
return false;
}
for (int i = 0; i < matches.Count; i++)
{
Match match = matches[i];
string raw = match.Groups[1].Value.Trim();
try
{
JObject obj = JObject.Parse(raw);
if (obj is null || !FenceIsTerminalPatch(obj))
{
continue;
}
truncated = reply.Substring(0, match.Index + match.Length).TrimEnd();
return true;
}
catch
{
// incomplete / invalid json inside fence
}
}
return false;
}
///
/// True when a closed fence is worth aborting the Ollama stream (real deliverable or tool hop).
///
static bool FenceIsTerminalPatch(JObject obj)
{
if (obj is null)
{
return false;
}
if (LooksLikeCardObject(obj))
{
return true;
}
if (obj["variants"] is JArray variants && variants.Count > 0)
{
return true;
}
if (HasValue(obj, "look_at") || HasValue(obj, "vision_from") || HasValue(obj, "vision_slots"))
{
return true;
}
if (HasValue(obj, "search_query") || HasValue(obj, "civitai_query"))
{
return true;
}
if (HasValue(obj, "memory_query") || HasValue(obj, "tag_query") || HasValue(obj, "inventory_query"))
{
return true;
}
if (obj["actions"] is JArray acts)
{
foreach (JToken a in acts)
{
string s = a?.ToString() ?? "";
if (string.IsNullOrWhiteSpace(s))
{
continue;
}
if (s.Equals("skill_load", StringComparison.OrdinalIgnoreCase)
|| s.Equals("persona_read", StringComparison.OrdinalIgnoreCase)
|| s.Equals("memory_get", StringComparison.OrdinalIgnoreCase)
|| s.Equals("memory_search", StringComparison.OrdinalIgnoreCase)
|| s.Equals("lookup_tags", StringComparison.OrdinalIgnoreCase)
|| s.Equals("list_inventory", StringComparison.OrdinalIgnoreCase)
|| s.Equals("search_civitai", StringComparison.OrdinalIgnoreCase)
|| s.Equals("interrupt", StringComparison.OrdinalIgnoreCase)
|| s.Equals("generate", StringComparison.OrdinalIgnoreCase)
|| s.Equals("memory_upsert", StringComparison.OrdinalIgnoreCase)
|| s.Equals("user_pref_upsert", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
string prompt = obj["prompt"]?.ToString()?.Trim() ?? "";
if (prompt.Length >= 48)
{
return true;
}
// Real param change without prose notes
if (HasValue(obj, "loras") || HasValue(obj, "aspect") || HasValue(obj, "steps")
|| HasValue(obj, "width") || HasValue(obj, "height") || HasValue(obj, "cfg")
|| HasValue(obj, "seed") || HasValue(obj, "controls")
|| HasValue(obj, "memories") || HasValue(obj, "user_prefs"))
{
return true;
}
// Weak: pack / creativity / intensity / empty actions / notes-only → keep streaming
return false;
}
static string ExtractSearchQuery(JObject patch)
{
if (patch is null)
{
return null;
}
string q = (patch["search_query"] ?? patch["civitai_query"])?.ToString()?.Trim();
return string.IsNullOrWhiteSpace(q) ? null : q;
}
static bool ActionsContain(JObject patch, string action)
{
if (patch?["actions"] is not JArray acts)
{
return false;
}
foreach (JToken a in acts)
{
if (string.Equals(a?.ToString(), action, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
static string ExtractMemoryQuery(JObject patch)
{
string q = patch?["memory_query"]?.ToString()?.Trim();
if (!string.IsNullOrWhiteSpace(q))
{
return q;
}
return ActionsContain(patch, "memory_search") ? ExtractSearchQuery(patch) : null;
}
static string ExtractTagQuery(JObject patch)
{
string q = patch?["tag_query"]?.ToString()?.Trim();
if (!string.IsNullOrWhiteSpace(q))
{
return q;
}
return ActionsContain(patch, "lookup_tags") ? ExtractSearchQuery(patch) : null;
}
static string NextToolHop(JObject patch, HashSet skip = null)
{
if (patch is null)
{
return null;
}
bool Skip(string tool) => skip is not null && skip.Contains(tool);
if (ActionsContain(patch, "memory_get") && !Skip("memory_get"))
{
return "memory_get";
}
if ((ActionsContain(patch, "memory_search") || !string.IsNullOrWhiteSpace(patch["memory_query"]?.ToString()))
&& !Skip("memory_search"))
{
return "memory_search";
}
if ((ActionsContain(patch, "lookup_tags") || !string.IsNullOrWhiteSpace(patch["tag_query"]?.ToString()))
&& !Skip("lookup_tags"))
{
return "lookup_tags";
}
if ((ActionsContain(patch, "list_inventory") || !string.IsNullOrWhiteSpace(patch["inventory_query"]?.ToString()))
&& !Skip("list_inventory"))
{
return "list_inventory";
}
if (ActionsContain(patch, "skill_load") && !Skip("skill_load"))
{
return "skill_load";
}
if (ActionsContain(patch, "persona_read") && !Skip("persona_read"))
{
return "persona_read";
}
if ((ActionsContain(patch, "search_civitai") || !string.IsNullOrWhiteSpace(ExtractSearchQuery(patch)))
&& !Skip("civitai"))
{
return "civitai";
}
return null;
}
}