feat(ai): implement AI memory system with context extraction and storage
Add comprehensive AI memory functionality including: - Memory context preparation and caching - Fact extraction from conversations - Memory search and retrieval with category support - JSON-based memory update operations (add/update/delete) - Memory deduplication and normalization - Integration with AI conversation flow The system extracts relevant facts from conversations, stores them with categories, and provides contextual memory to enhance AI responses.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -46,3 +46,4 @@ node_modules/
|
||||
gemini-websocket-proxy/
|
||||
Tools/dark-server/dark-server.js
|
||||
Tools/rimworld_cpt_data.jsonl
|
||||
Tools/mem0-1.0.0/
|
||||
|
||||
Binary file not shown.
Binary file not shown.
69
Source/WulaFallenEmpire/EventSystem/AI/AIMemoryEntry.cs
Normal file
69
Source/WulaFallenEmpire/EventSystem/AI/AIMemoryEntry.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using System;
|
||||
|
||||
namespace WulaFallenEmpire.EventSystem.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a single memory entry extracted from conversations.
|
||||
/// Inspired by Mem0's memory structure.
|
||||
/// </summary>
|
||||
public class AIMemoryEntry
|
||||
{
|
||||
/// <summary>Unique identifier for this memory</summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>The actual memory content/fact</summary>
|
||||
public string Fact { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Category of memory: preference, personal, plan, colony, misc
|
||||
/// </summary>
|
||||
public string Category { get; set; }
|
||||
|
||||
/// <summary>Game ticks when this memory was created</summary>
|
||||
public long CreatedTicks { get; set; }
|
||||
|
||||
/// <summary>Game ticks when this memory was last updated</summary>
|
||||
public long UpdatedTicks { get; set; }
|
||||
|
||||
/// <summary>Number of times this memory has been accessed/retrieved</summary>
|
||||
public int AccessCount { get; set; }
|
||||
|
||||
/// <summary>Hash of the fact for quick duplicate detection</summary>
|
||||
public string Hash { get; set; }
|
||||
|
||||
public AIMemoryEntry()
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N").Substring(0, 12);
|
||||
CreatedTicks = 0;
|
||||
UpdatedTicks = 0;
|
||||
AccessCount = 0;
|
||||
Category = "misc";
|
||||
}
|
||||
|
||||
public AIMemoryEntry(string fact, string category = "misc") : this()
|
||||
{
|
||||
Fact = fact;
|
||||
Category = category ?? "misc";
|
||||
Hash = ComputeHash(fact);
|
||||
}
|
||||
|
||||
public static string ComputeHash(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return "";
|
||||
// Simple hash based on normalized text
|
||||
string normalized = text.ToLowerInvariant().Trim();
|
||||
return normalized.GetHashCode().ToString("X8");
|
||||
}
|
||||
|
||||
public void UpdateFact(string newFact)
|
||||
{
|
||||
Fact = newFact;
|
||||
Hash = ComputeHash(newFact);
|
||||
}
|
||||
|
||||
public void MarkAccessed()
|
||||
{
|
||||
AccessCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
484
Source/WulaFallenEmpire/EventSystem/AI/AIMemoryManager.cs
Normal file
484
Source/WulaFallenEmpire/EventSystem/AI/AIMemoryManager.cs
Normal file
@@ -0,0 +1,484 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using RimWorld.Planet;
|
||||
using Verse;
|
||||
|
||||
namespace WulaFallenEmpire.EventSystem.AI
|
||||
{
|
||||
public class AIMemoryManager : WorldComponent
|
||||
{
|
||||
private const string MemoryFolderName = "WulaAIMemory";
|
||||
private const string MemoryVersion = "1.0";
|
||||
private const int RecencyTickWindow = 60000;
|
||||
private string _saveId;
|
||||
private List<AIMemoryEntry> _memories = new List<AIMemoryEntry>();
|
||||
private bool _loaded;
|
||||
|
||||
public AIMemoryManager(World world) : base(world)
|
||||
{
|
||||
}
|
||||
|
||||
public IReadOnlyList<AIMemoryEntry> GetAllMemories()
|
||||
{
|
||||
EnsureLoaded();
|
||||
return _memories.ToList();
|
||||
}
|
||||
|
||||
public AIMemoryEntry AddMemory(string fact, string category = "misc")
|
||||
{
|
||||
EnsureLoaded();
|
||||
if (string.IsNullOrWhiteSpace(fact)) return null;
|
||||
|
||||
string normalizedCategory = NormalizeCategory(category);
|
||||
string hash = AIMemoryEntry.ComputeHash(fact);
|
||||
string normalizedFact = NormalizeFact(fact);
|
||||
var existing = _memories.FirstOrDefault(m => m != null &&
|
||||
(string.Equals(m.Hash, hash, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(NormalizeFact(m.Fact), normalizedFact, StringComparison.Ordinal)));
|
||||
long now = GetCurrentTicks();
|
||||
if (existing != null)
|
||||
{
|
||||
existing.UpdateFact(fact);
|
||||
existing.Category = normalizedCategory;
|
||||
existing.UpdatedTicks = now;
|
||||
SaveToFile();
|
||||
return existing;
|
||||
}
|
||||
|
||||
var entry = new AIMemoryEntry(fact, normalizedCategory)
|
||||
{
|
||||
CreatedTicks = now,
|
||||
UpdatedTicks = now,
|
||||
AccessCount = 0
|
||||
};
|
||||
_memories.Add(entry);
|
||||
SaveToFile();
|
||||
return entry;
|
||||
}
|
||||
|
||||
public bool UpdateMemory(string id, string newFact, string category = null)
|
||||
{
|
||||
EnsureLoaded();
|
||||
if (string.IsNullOrWhiteSpace(id)) return false;
|
||||
|
||||
var entry = _memories.FirstOrDefault(m => string.Equals(m.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
if (entry == null) return false;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(newFact))
|
||||
{
|
||||
entry.UpdateFact(newFact);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(category))
|
||||
{
|
||||
entry.Category = NormalizeCategory(category);
|
||||
}
|
||||
|
||||
entry.UpdatedTicks = GetCurrentTicks();
|
||||
SaveToFile();
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool DeleteMemory(string id)
|
||||
{
|
||||
EnsureLoaded();
|
||||
if (string.IsNullOrWhiteSpace(id)) return false;
|
||||
|
||||
int removed = _memories.RemoveAll(m => string.Equals(m.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
if (removed > 0)
|
||||
{
|
||||
SaveToFile();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public List<AIMemoryEntry> SearchMemories(string query, int limit = 5)
|
||||
{
|
||||
EnsureLoaded();
|
||||
if (string.IsNullOrWhiteSpace(query)) return new List<AIMemoryEntry>();
|
||||
|
||||
string normalizedQuery = query.Trim();
|
||||
List<string> tokens = Tokenize(normalizedQuery);
|
||||
|
||||
long now = GetCurrentTicks();
|
||||
var scored = new List<(AIMemoryEntry entry, float score)>();
|
||||
|
||||
foreach (var entry in _memories)
|
||||
{
|
||||
if (entry == null || string.IsNullOrWhiteSpace(entry.Fact)) continue;
|
||||
float score = ComputeScore(entry, normalizedQuery, tokens, now);
|
||||
if (score <= 0f) continue;
|
||||
scored.Add((entry, score));
|
||||
}
|
||||
|
||||
var results = scored
|
||||
.OrderByDescending(s => s.score)
|
||||
.ThenByDescending(s => s.entry.UpdatedTicks)
|
||||
.Take(Math.Max(1, limit))
|
||||
.Select(s => s.entry)
|
||||
.ToList();
|
||||
|
||||
if (results.Count > 0)
|
||||
{
|
||||
foreach (var entry in results)
|
||||
{
|
||||
entry.MarkAccessed();
|
||||
entry.UpdatedTicks = now;
|
||||
}
|
||||
SaveToFile();
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public List<AIMemoryEntry> GetRecentMemories(int limit = 5)
|
||||
{
|
||||
EnsureLoaded();
|
||||
return _memories
|
||||
.Where(m => m != null && !string.IsNullOrWhiteSpace(m.Fact))
|
||||
.OrderByDescending(m => m.UpdatedTicks)
|
||||
.ThenByDescending(m => m.CreatedTicks)
|
||||
.Take(Math.Max(1, limit))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void EnsureLoaded()
|
||||
{
|
||||
if (_loaded) return;
|
||||
LoadFromFile();
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private string GetSaveDirectory()
|
||||
{
|
||||
string path = Path.Combine(GenFilePaths.SaveDataFolderPath, MemoryFolderName);
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
Directory.CreateDirectory(path);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
private string GetFilePath()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_saveId))
|
||||
{
|
||||
_saveId = Guid.NewGuid().ToString("N");
|
||||
}
|
||||
return Path.Combine(GetSaveDirectory(), $"{_saveId}.json");
|
||||
}
|
||||
|
||||
private void LoadFromFile()
|
||||
{
|
||||
_memories = new List<AIMemoryEntry>();
|
||||
|
||||
string path = GetFilePath();
|
||||
if (!File.Exists(path)) return;
|
||||
|
||||
try
|
||||
{
|
||||
string json = File.ReadAllText(path);
|
||||
if (string.IsNullOrWhiteSpace(json)) return;
|
||||
|
||||
string array = ExtractJsonArray(json, "memories");
|
||||
if (string.IsNullOrWhiteSpace(array)) return;
|
||||
|
||||
foreach (string obj in ExtractJsonObjects(array))
|
||||
{
|
||||
var dict = SimpleJsonParser.Parse(obj);
|
||||
if (dict == null || dict.Count == 0) continue;
|
||||
|
||||
var entry = new AIMemoryEntry();
|
||||
if (dict.TryGetValue("id", out string id) && !string.IsNullOrWhiteSpace(id)) entry.Id = id;
|
||||
if (dict.TryGetValue("fact", out string fact)) entry.Fact = fact;
|
||||
if (dict.TryGetValue("category", out string category)) entry.Category = NormalizeCategory(category);
|
||||
if (dict.TryGetValue("createdTicks", out string created) && long.TryParse(created, NumberStyles.Integer, CultureInfo.InvariantCulture, out long createdTicks)) entry.CreatedTicks = createdTicks;
|
||||
if (dict.TryGetValue("updatedTicks", out string updated) && long.TryParse(updated, NumberStyles.Integer, CultureInfo.InvariantCulture, out long updatedTicks)) entry.UpdatedTicks = updatedTicks;
|
||||
if (dict.TryGetValue("accessCount", out string access) && int.TryParse(access, NumberStyles.Integer, CultureInfo.InvariantCulture, out int accessCount)) entry.AccessCount = accessCount;
|
||||
if (dict.TryGetValue("hash", out string hash)) entry.Hash = hash;
|
||||
if (string.IsNullOrWhiteSpace(entry.Hash))
|
||||
{
|
||||
entry.Hash = AIMemoryEntry.ComputeHash(entry.Fact);
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(entry.Category)) entry.Category = "misc";
|
||||
_memories.Add(entry);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[WulaAI] Failed to load memory file: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveToFile()
|
||||
{
|
||||
string path = GetFilePath();
|
||||
try
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.Append("{");
|
||||
sb.Append("\"version\":\"").Append(MemoryVersion).Append("\",");
|
||||
sb.Append("\"memories\":[");
|
||||
bool first = true;
|
||||
foreach (var memory in _memories)
|
||||
{
|
||||
if (memory == null) continue;
|
||||
if (!first) sb.Append(",");
|
||||
first = false;
|
||||
sb.Append("{");
|
||||
sb.Append("\"id\":\"").Append(EscapeJson(memory.Id)).Append("\",");
|
||||
sb.Append("\"fact\":\"").Append(EscapeJson(memory.Fact)).Append("\",");
|
||||
sb.Append("\"category\":\"").Append(EscapeJson(memory.Category)).Append("\",");
|
||||
sb.Append("\"createdTicks\":").Append(memory.CreatedTicks.ToString(CultureInfo.InvariantCulture)).Append(",");
|
||||
sb.Append("\"updatedTicks\":").Append(memory.UpdatedTicks.ToString(CultureInfo.InvariantCulture)).Append(",");
|
||||
sb.Append("\"accessCount\":").Append(memory.AccessCount.ToString(CultureInfo.InvariantCulture)).Append(",");
|
||||
sb.Append("\"hash\":\"").Append(EscapeJson(memory.Hash)).Append("\"");
|
||||
sb.Append("}");
|
||||
}
|
||||
sb.Append("]}");
|
||||
File.WriteAllText(path, sb.ToString());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[WulaAI] Failed to save memory file: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
public override void ExposeData()
|
||||
{
|
||||
base.ExposeData();
|
||||
Scribe_Values.Look(ref _saveId, "WulaAIMemoryId");
|
||||
|
||||
if (Scribe.mode == LoadSaveMode.PostLoadInit && string.IsNullOrEmpty(_saveId))
|
||||
{
|
||||
_saveId = Guid.NewGuid().ToString("N");
|
||||
_loaded = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static long GetCurrentTicks()
|
||||
{
|
||||
return Find.TickManager?.TicksGame ?? 0;
|
||||
}
|
||||
|
||||
private static string NormalizeCategory(string category)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(category)) return "misc";
|
||||
string lower = category.Trim().ToLowerInvariant();
|
||||
switch (lower)
|
||||
{
|
||||
case "preference":
|
||||
case "personal":
|
||||
case "plan":
|
||||
case "colony":
|
||||
case "misc":
|
||||
return lower;
|
||||
default:
|
||||
return "misc";
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeFact(string fact)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(fact) ? "" : fact.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static float ComputeScore(AIMemoryEntry entry, string query, List<string> tokens, long now)
|
||||
{
|
||||
string fact = entry.Fact ?? "";
|
||||
if (string.IsNullOrWhiteSpace(fact)) return 0f;
|
||||
|
||||
string factLower = fact.ToLowerInvariant();
|
||||
string queryLower = query.ToLowerInvariant();
|
||||
|
||||
float score = 0f;
|
||||
if (string.Equals(factLower, queryLower, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score = 1.2f;
|
||||
}
|
||||
else if (factLower.Contains(queryLower) || queryLower.Contains(factLower))
|
||||
{
|
||||
score = 0.9f;
|
||||
}
|
||||
|
||||
if (tokens.Count > 0)
|
||||
{
|
||||
int matches = 0;
|
||||
foreach (string token in tokens)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token)) continue;
|
||||
if (factLower.Contains(token)) matches++;
|
||||
}
|
||||
float coverage = matches / (float)Math.Max(1, tokens.Count);
|
||||
score = Math.Max(score, 0.3f * coverage);
|
||||
}
|
||||
|
||||
long updated = entry.UpdatedTicks > 0 ? entry.UpdatedTicks : entry.CreatedTicks;
|
||||
long age = Math.Max(0, now - updated);
|
||||
float recency = 1f / (1f + (age / (float)RecencyTickWindow));
|
||||
float accessBoost = 1f + Math.Min(0.2f, entry.AccessCount * 0.02f);
|
||||
return score * recency * accessBoost;
|
||||
}
|
||||
|
||||
private static List<string> Tokenize(string text)
|
||||
{
|
||||
var tokens = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(text)) return tokens;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
foreach (char c in text)
|
||||
{
|
||||
if (char.IsLetterOrDigit(c))
|
||||
{
|
||||
sb.Append(char.ToLowerInvariant(c));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
tokens.Add(sb.ToString());
|
||||
sb.Length = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sb.Length > 0) tokens.Add(sb.ToString());
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private static string EscapeJson(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return "";
|
||||
return value.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r");
|
||||
}
|
||||
|
||||
private static string ExtractJsonArray(string json, string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json) || string.IsNullOrWhiteSpace(key)) return null;
|
||||
|
||||
string keyPattern = $"\"{key}\"";
|
||||
int keyIndex = json.IndexOf(keyPattern, StringComparison.OrdinalIgnoreCase);
|
||||
if (keyIndex == -1) return null;
|
||||
|
||||
int arrayStart = json.IndexOf('[', keyIndex);
|
||||
if (arrayStart == -1) return null;
|
||||
|
||||
int arrayEnd = FindMatchingBracket(json, arrayStart);
|
||||
if (arrayEnd == -1) return null;
|
||||
|
||||
return json.Substring(arrayStart + 1, arrayEnd - arrayStart - 1);
|
||||
}
|
||||
|
||||
private static List<string> ExtractJsonObjects(string arrayContent)
|
||||
{
|
||||
var objects = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(arrayContent)) return objects;
|
||||
|
||||
int depth = 0;
|
||||
int start = -1;
|
||||
bool inString = false;
|
||||
bool escaped = false;
|
||||
|
||||
for (int i = 0; i < arrayContent.Length; i++)
|
||||
{
|
||||
char c = arrayContent[i];
|
||||
if (inString)
|
||||
{
|
||||
if (escaped)
|
||||
{
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (c == '\\')
|
||||
{
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (c == '"')
|
||||
{
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '"')
|
||||
{
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '{')
|
||||
{
|
||||
if (depth == 0) start = i;
|
||||
depth++;
|
||||
continue;
|
||||
}
|
||||
if (c == '}')
|
||||
{
|
||||
depth--;
|
||||
if (depth == 0 && start >= 0)
|
||||
{
|
||||
objects.Add(arrayContent.Substring(start, i - start + 1));
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return objects;
|
||||
}
|
||||
|
||||
private static int FindMatchingBracket(string json, int startIndex)
|
||||
{
|
||||
int depth = 0;
|
||||
bool inString = false;
|
||||
bool escaped = false;
|
||||
|
||||
for (int i = startIndex; i < json.Length; i++)
|
||||
{
|
||||
char c = json[i];
|
||||
if (inString)
|
||||
{
|
||||
if (escaped)
|
||||
{
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (c == '\\')
|
||||
{
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (c == '"')
|
||||
{
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '"')
|
||||
{
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '[')
|
||||
{
|
||||
depth++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == ']')
|
||||
{
|
||||
depth--;
|
||||
if (depth == 0) return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
45
Source/WulaFallenEmpire/EventSystem/AI/MemoryPrompts.cs
Normal file
45
Source/WulaFallenEmpire/EventSystem/AI/MemoryPrompts.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace WulaFallenEmpire.EventSystem.AI
|
||||
{
|
||||
public static class MemoryPrompts
|
||||
{
|
||||
public const string FactExtractionPrompt =
|
||||
@"You are extracting long-term memory about the player from the conversation below.
|
||||
Return JSON only, no extra text.
|
||||
Schema:
|
||||
{{""facts"":[{{""text"":""..."",""category"":""preference|personal|plan|colony|misc""}}]}}
|
||||
Rules:
|
||||
- Keep only stable, reusable facts about the player or colony.
|
||||
- Ignore transient tool results, numbers, or one-off actions.
|
||||
- Do not invent facts.
|
||||
Conversation:
|
||||
{0}";
|
||||
|
||||
public const string MemoryUpdatePrompt =
|
||||
@"You are updating a memory store.
|
||||
Given existing memories and new facts, decide ADD, UPDATE, DELETE, or NONE.
|
||||
Return JSON only, no extra text.
|
||||
Schema:
|
||||
{{""memory"":[{{""id"":""..."",""text"":""..."",""category"":""preference|personal|plan|colony|misc"",""event"":""ADD|UPDATE|DELETE|NONE""}}]}}
|
||||
Rules:
|
||||
- UPDATE if a new fact refines or corrects an existing memory.
|
||||
- DELETE if a memory is contradicted by new facts.
|
||||
- ADD for genuinely new information.
|
||||
- NONE if no change is needed.
|
||||
Existing memories (JSON):
|
||||
{0}
|
||||
New facts (JSON):
|
||||
{1}";
|
||||
|
||||
public static string BuildFactExtractionPrompt(string conversation)
|
||||
{
|
||||
return string.Format(CultureInfo.InvariantCulture, FactExtractionPrompt, conversation ?? "");
|
||||
}
|
||||
|
||||
public static string BuildMemoryUpdatePrompt(string existingMemoriesJson, string newFactsJson)
|
||||
{
|
||||
return string.Format(CultureInfo.InvariantCulture, MemoryUpdatePrompt, existingMemoriesJson ?? "[]", newFactsJson ?? "[]");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
|
||||
using RimWorld;
|
||||
using UnityEngine;
|
||||
using Verse;
|
||||
using WulaFallenEmpire.EventSystem.AI;
|
||||
using WulaFallenEmpire.EventSystem.AI.Tools;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
@@ -44,6 +45,13 @@ namespace WulaFallenEmpire.EventSystem.AI.UI
|
||||
private const int DefaultMaxHistoryTokens = 100000;
|
||||
private const int CharsPerToken = 4;
|
||||
private const int ThinkingPhaseTotal = 3;
|
||||
private const int MemorySearchLimit = 6;
|
||||
private const int MemoryFactMaxChars = 200;
|
||||
private const int MemoryPromptMaxChars = 1600;
|
||||
private const int MemoryUpdateMaxMemories = 40;
|
||||
private string _memoryContextCache = "";
|
||||
private string _memoryContextQuery = "";
|
||||
private bool _memoryExtractionInFlight = false;
|
||||
|
||||
private enum RequestPhase
|
||||
{
|
||||
@@ -59,6 +67,20 @@ namespace WulaFallenEmpire.EventSystem.AI.UI
|
||||
public bool AnyActionError;
|
||||
}
|
||||
|
||||
private struct MemoryFact
|
||||
{
|
||||
public string Text;
|
||||
public string Category;
|
||||
}
|
||||
|
||||
private struct MemoryUpdate
|
||||
{
|
||||
public string Event;
|
||||
public string Id;
|
||||
public string Text;
|
||||
public string Category;
|
||||
}
|
||||
|
||||
private void SetThinkingPhase(int phaseIndex, bool isRetry)
|
||||
{
|
||||
_thinkingPhaseIndex = Math.Max(1, Math.Min(ThinkingPhaseTotal, phaseIndex));
|
||||
@@ -233,10 +255,15 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
|
||||
{
|
||||
// Use XML persona if available, otherwise default
|
||||
string persona = !string.IsNullOrEmpty(def.aiSystemInstruction) ? def.aiSystemInstruction : DefaultPersona;
|
||||
|
||||
|
||||
string memoryContext = _memoryContextCache;
|
||||
string personaWithMemory = string.IsNullOrWhiteSpace(memoryContext)
|
||||
? persona
|
||||
: persona + "\n\n" + memoryContext;
|
||||
|
||||
string fullInstruction = toolsEnabled
|
||||
? (persona + "\n" + ToolRulesInstruction + "\n" + toolsForThisPhase)
|
||||
: persona;
|
||||
? (personaWithMemory + "\n" + ToolRulesInstruction + "\n" + toolsForThisPhase)
|
||||
: personaWithMemory;
|
||||
|
||||
string language = LanguageDatabase.activeLanguage.FriendlyNameNative;
|
||||
var eventVarManager = Find.World.GetComponent<EventVariableManager>();
|
||||
@@ -494,6 +521,542 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
|
||||
return context;
|
||||
}
|
||||
|
||||
private void PrepareMemoryContext()
|
||||
{
|
||||
string lastUserMessage = _history.LastOrDefault(entry => string.Equals(entry.role, "user", StringComparison.OrdinalIgnoreCase)).message;
|
||||
if (string.IsNullOrWhiteSpace(lastUserMessage))
|
||||
{
|
||||
_memoryContextCache = "";
|
||||
_memoryContextQuery = "";
|
||||
return;
|
||||
}
|
||||
|
||||
_memoryContextQuery = lastUserMessage;
|
||||
_memoryContextCache = BuildMemoryContext(lastUserMessage);
|
||||
}
|
||||
|
||||
private string BuildMemoryContext(string userQuery)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userQuery)) return "";
|
||||
|
||||
var memoryManager = Find.World?.GetComponent<AIMemoryManager>();
|
||||
if (memoryManager == null) return "";
|
||||
|
||||
var memories = memoryManager.SearchMemories(userQuery, MemorySearchLimit);
|
||||
if (memories == null || memories.Count == 0) return "";
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.AppendLine("# MEMORY (Relevant Facts)");
|
||||
foreach (var memory in memories)
|
||||
{
|
||||
if (memory == null || string.IsNullOrWhiteSpace(memory.Fact)) continue;
|
||||
string category = string.IsNullOrWhiteSpace(memory.Category) ? "misc" : memory.Category.Trim();
|
||||
string fact = TrimMemoryFact(memory.Fact, MemoryFactMaxChars);
|
||||
if (string.IsNullOrWhiteSpace(fact)) continue;
|
||||
sb.AppendLine($"- [{category}] {fact}");
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static string TrimMemoryFact(string fact, int maxChars)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fact)) return "";
|
||||
string trimmed = fact.Trim();
|
||||
if (trimmed.Length <= maxChars) return trimmed;
|
||||
return trimmed.Substring(0, maxChars) + "...";
|
||||
}
|
||||
|
||||
private string BuildMemoryExtractionConversation()
|
||||
{
|
||||
if (_history == null || _history.Count == 0) return "";
|
||||
|
||||
const int maxTurns = 6;
|
||||
var lines = new List<string>();
|
||||
|
||||
for (int i = _history.Count - 1; i >= 0 && lines.Count < maxTurns; i--)
|
||||
{
|
||||
var entry = _history[i];
|
||||
if (!string.Equals(entry.role, "user", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(entry.role, "assistant", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string content = entry.role == "assistant"
|
||||
? ParseResponseForDisplay(entry.message)
|
||||
: entry.message;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(content)) continue;
|
||||
|
||||
lines.Add($"{entry.role}: {content.Trim()}");
|
||||
}
|
||||
|
||||
lines.Reverse();
|
||||
string snippet = string.Join("\n", lines);
|
||||
return TrimForPrompt(snippet, MemoryPromptMaxChars);
|
||||
}
|
||||
|
||||
private async Task ExtractAndUpdateMemoriesAsync()
|
||||
{
|
||||
if (_memoryExtractionInFlight) return;
|
||||
_memoryExtractionInFlight = true;
|
||||
try
|
||||
{
|
||||
var world = Find.World;
|
||||
if (world == null) return;
|
||||
|
||||
var memoryManager = world.GetComponent<AIMemoryManager>();
|
||||
if (memoryManager == null) return;
|
||||
|
||||
string conversation = BuildMemoryExtractionConversation();
|
||||
if (string.IsNullOrWhiteSpace(conversation)) return;
|
||||
|
||||
var settings = WulaFallenEmpire.WulaFallenEmpireMod.settings;
|
||||
if (settings == null) return;
|
||||
|
||||
var client = new SimpleAIClient(settings.apiKey, settings.baseUrl, settings.model);
|
||||
string extractPrompt = MemoryPrompts.BuildFactExtractionPrompt(conversation);
|
||||
string extractResponse = await client.GetChatCompletionAsync(extractPrompt, new List<(string role, string message)>(), maxTokens: 256, temperature: 0.2f);
|
||||
if (string.IsNullOrWhiteSpace(extractResponse)) return;
|
||||
|
||||
List<MemoryFact> facts = ParseFactsResponse(extractResponse);
|
||||
if (facts.Count == 0) return;
|
||||
|
||||
var existing = memoryManager.GetAllMemories()
|
||||
.OrderByDescending(m => m.UpdatedTicks)
|
||||
.ThenByDescending(m => m.CreatedTicks)
|
||||
.Take(MemoryUpdateMaxMemories)
|
||||
.ToList();
|
||||
|
||||
if (existing.Count == 0)
|
||||
{
|
||||
AddFactsToMemory(memoryManager, facts);
|
||||
return;
|
||||
}
|
||||
|
||||
string existingJson = BuildMemoriesJson(existing);
|
||||
string factsJson = BuildFactsJson(facts);
|
||||
string updatePrompt = MemoryPrompts.BuildMemoryUpdatePrompt(existingJson, factsJson);
|
||||
string updateResponse = await client.GetChatCompletionAsync(updatePrompt, new List<(string role, string message)>(), maxTokens: 256, temperature: 0.2f);
|
||||
if (string.IsNullOrWhiteSpace(updateResponse))
|
||||
{
|
||||
AddFactsToMemory(memoryManager, facts);
|
||||
return;
|
||||
}
|
||||
|
||||
List<MemoryUpdate> updates = ParseMemoryUpdateResponse(updateResponse);
|
||||
if (updates.Count == 0)
|
||||
{
|
||||
AddFactsToMemory(memoryManager, facts);
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyMemoryUpdates(memoryManager, updates, facts);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WulaLog.Debug($"[WulaAI] Memory extraction failed: {ex}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_memoryExtractionInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddFactsToMemory(AIMemoryManager memoryManager, List<MemoryFact> facts)
|
||||
{
|
||||
if (memoryManager == null || facts == null) return;
|
||||
|
||||
foreach (var fact in facts)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fact.Text)) continue;
|
||||
memoryManager.AddMemory(fact.Text, fact.Category);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyMemoryUpdates(AIMemoryManager memoryManager, List<MemoryUpdate> updates, List<MemoryFact> fallbackFacts)
|
||||
{
|
||||
if (memoryManager == null || updates == null) return;
|
||||
|
||||
bool applied = false;
|
||||
bool anyDecision = false;
|
||||
foreach (var update in updates)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(update.Event)) continue;
|
||||
|
||||
switch (update.Event.Trim().ToUpperInvariant())
|
||||
{
|
||||
case "ADD":
|
||||
anyDecision = true;
|
||||
if (!string.IsNullOrWhiteSpace(update.Text))
|
||||
{
|
||||
memoryManager.AddMemory(update.Text, update.Category);
|
||||
applied = true;
|
||||
}
|
||||
break;
|
||||
case "UPDATE":
|
||||
anyDecision = true;
|
||||
if (!string.IsNullOrWhiteSpace(update.Id))
|
||||
{
|
||||
if (memoryManager.UpdateMemory(update.Id, update.Text, update.Category))
|
||||
{
|
||||
applied = true;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(update.Text))
|
||||
{
|
||||
memoryManager.AddMemory(update.Text, update.Category);
|
||||
applied = true;
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(update.Text))
|
||||
{
|
||||
memoryManager.AddMemory(update.Text, update.Category);
|
||||
applied = true;
|
||||
}
|
||||
break;
|
||||
case "DELETE":
|
||||
anyDecision = true;
|
||||
if (!string.IsNullOrWhiteSpace(update.Id) && memoryManager.DeleteMemory(update.Id))
|
||||
{
|
||||
applied = true;
|
||||
}
|
||||
break;
|
||||
case "NONE":
|
||||
anyDecision = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!applied && !anyDecision)
|
||||
{
|
||||
AddFactsToMemory(memoryManager, fallbackFacts);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildMemoriesJson(List<AIMemoryEntry> memories)
|
||||
{
|
||||
if (memories == null || memories.Count == 0) return "[]";
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.Append("[");
|
||||
bool first = true;
|
||||
foreach (var memory in memories)
|
||||
{
|
||||
if (memory == null || string.IsNullOrWhiteSpace(memory.Fact)) continue;
|
||||
if (!first) sb.Append(",");
|
||||
first = false;
|
||||
sb.Append("{");
|
||||
sb.Append("\"id\":\"").Append(EscapeJson(memory.Id)).Append("\",");
|
||||
sb.Append("\"text\":\"").Append(EscapeJson(memory.Fact)).Append("\",");
|
||||
sb.Append("\"category\":\"").Append(EscapeJson(memory.Category)).Append("\"");
|
||||
sb.Append("}");
|
||||
}
|
||||
sb.Append("]");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string BuildFactsJson(List<MemoryFact> facts)
|
||||
{
|
||||
if (facts == null || facts.Count == 0) return "[]";
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.Append("[");
|
||||
bool first = true;
|
||||
foreach (var fact in facts)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fact.Text)) continue;
|
||||
if (!first) sb.Append(",");
|
||||
first = false;
|
||||
sb.Append("{");
|
||||
sb.Append("\"text\":\"").Append(EscapeJson(fact.Text)).Append("\",");
|
||||
sb.Append("\"category\":\"").Append(EscapeJson(fact.Category)).Append("\"");
|
||||
sb.Append("}");
|
||||
}
|
||||
sb.Append("]");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static List<MemoryFact> ParseFactsResponse(string response)
|
||||
{
|
||||
var facts = new List<MemoryFact>();
|
||||
string array = TryExtractJsonArray(response, "facts");
|
||||
if (string.IsNullOrWhiteSpace(array)) return facts;
|
||||
|
||||
var objects = ExtractJsonObjects(array);
|
||||
if (objects.Count > 0)
|
||||
{
|
||||
foreach (string obj in objects)
|
||||
{
|
||||
var dict = SimpleJsonParser.Parse(obj);
|
||||
if (dict == null || dict.Count == 0) continue;
|
||||
|
||||
string text = GetDictionaryValue(dict, "text") ?? GetDictionaryValue(dict, "fact");
|
||||
if (string.IsNullOrWhiteSpace(text)) continue;
|
||||
|
||||
string category = NormalizeMemoryCategory(GetDictionaryValue(dict, "category"));
|
||||
facts.Add(new MemoryFact { Text = text.Trim(), Category = category });
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (string item in SplitJsonArrayValues(array))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item)) continue;
|
||||
facts.Add(new MemoryFact { Text = item.Trim(), Category = "misc" });
|
||||
}
|
||||
}
|
||||
|
||||
var deduped = new List<MemoryFact>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var fact in facts)
|
||||
{
|
||||
string hash = AIMemoryEntry.ComputeHash(fact.Text);
|
||||
if (string.IsNullOrWhiteSpace(hash) || !seen.Add(hash)) continue;
|
||||
deduped.Add(fact);
|
||||
}
|
||||
|
||||
return deduped;
|
||||
}
|
||||
|
||||
private static List<MemoryUpdate> ParseMemoryUpdateResponse(string response)
|
||||
{
|
||||
var updates = new List<MemoryUpdate>();
|
||||
string array = TryExtractJsonArray(response, "memory") ?? TryExtractJsonArray(response, "memories");
|
||||
if (string.IsNullOrWhiteSpace(array)) return updates;
|
||||
|
||||
foreach (string obj in ExtractJsonObjects(array))
|
||||
{
|
||||
var dict = SimpleJsonParser.Parse(obj);
|
||||
if (dict == null || dict.Count == 0) continue;
|
||||
|
||||
string evt = GetDictionaryValue(dict, "event") ?? GetDictionaryValue(dict, "action");
|
||||
if (string.IsNullOrWhiteSpace(evt)) continue;
|
||||
|
||||
updates.Add(new MemoryUpdate
|
||||
{
|
||||
Event = evt.Trim().ToUpperInvariant(),
|
||||
Id = GetDictionaryValue(dict, "id"),
|
||||
Text = GetDictionaryValue(dict, "text") ?? GetDictionaryValue(dict, "fact"),
|
||||
Category = NormalizeMemoryCategory(GetDictionaryValue(dict, "category"))
|
||||
});
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
private static string NormalizeMemoryCategory(string category)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(category)) return "misc";
|
||||
string normalized = category.Trim().ToLowerInvariant();
|
||||
switch (normalized)
|
||||
{
|
||||
case "preference":
|
||||
case "personal":
|
||||
case "plan":
|
||||
case "colony":
|
||||
case "misc":
|
||||
return normalized;
|
||||
default:
|
||||
return "misc";
|
||||
}
|
||||
}
|
||||
|
||||
private static string TryExtractJsonArray(string json, string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json) || string.IsNullOrWhiteSpace(key)) return null;
|
||||
|
||||
string keyPattern = $"\"{key}\"";
|
||||
int keyIndex = json.IndexOf(keyPattern, StringComparison.OrdinalIgnoreCase);
|
||||
if (keyIndex == -1) return null;
|
||||
|
||||
int arrayStart = json.IndexOf('[', keyIndex);
|
||||
if (arrayStart == -1) return null;
|
||||
|
||||
int arrayEnd = FindMatchingBracket(json, arrayStart);
|
||||
if (arrayEnd == -1) return null;
|
||||
|
||||
return json.Substring(arrayStart + 1, arrayEnd - arrayStart - 1);
|
||||
}
|
||||
|
||||
private static List<string> ExtractJsonObjects(string arrayContent)
|
||||
{
|
||||
var objects = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(arrayContent)) return objects;
|
||||
|
||||
int depth = 0;
|
||||
int start = -1;
|
||||
bool inString = false;
|
||||
bool escaped = false;
|
||||
|
||||
for (int i = 0; i < arrayContent.Length; i++)
|
||||
{
|
||||
char c = arrayContent[i];
|
||||
if (inString)
|
||||
{
|
||||
if (escaped)
|
||||
{
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (c == '\\')
|
||||
{
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (c == '"')
|
||||
{
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '"')
|
||||
{
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '{')
|
||||
{
|
||||
if (depth == 0) start = i;
|
||||
depth++;
|
||||
continue;
|
||||
}
|
||||
if (c == '}')
|
||||
{
|
||||
depth--;
|
||||
if (depth == 0 && start >= 0)
|
||||
{
|
||||
objects.Add(arrayContent.Substring(start, i - start + 1));
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return objects;
|
||||
}
|
||||
|
||||
private static List<string> SplitJsonArrayValues(string arrayContent)
|
||||
{
|
||||
var items = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(arrayContent)) return items;
|
||||
|
||||
bool inString = false;
|
||||
bool escaped = false;
|
||||
int start = 0;
|
||||
|
||||
for (int i = 0; i < arrayContent.Length; i++)
|
||||
{
|
||||
char c = arrayContent[i];
|
||||
if (inString)
|
||||
{
|
||||
if (escaped)
|
||||
{
|
||||
escaped = false;
|
||||
}
|
||||
else if (c == '\\')
|
||||
{
|
||||
escaped = true;
|
||||
}
|
||||
else if (c == '"')
|
||||
{
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '"')
|
||||
{
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == ',')
|
||||
{
|
||||
string part = arrayContent.Substring(start, i - start);
|
||||
items.Add(UnescapeJsonString(part.Trim().Trim('"')));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (start < arrayContent.Length)
|
||||
{
|
||||
string part = arrayContent.Substring(start);
|
||||
items.Add(UnescapeJsonString(part.Trim().Trim('"')));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private static string UnescapeJsonString(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return "";
|
||||
return value.Replace("\\r", "\r").Replace("\\n", "\n").Replace("\\\"", "\"").Replace("\\\\", "\\");
|
||||
}
|
||||
|
||||
private static string GetDictionaryValue(Dictionary<string, string> dict, string key)
|
||||
{
|
||||
if (dict == null || string.IsNullOrWhiteSpace(key)) return null;
|
||||
return dict.TryGetValue(key, out string value) ? value : null;
|
||||
}
|
||||
|
||||
private static string EscapeJson(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return "";
|
||||
return value.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r");
|
||||
}
|
||||
|
||||
private static int FindMatchingBracket(string json, int startIndex)
|
||||
{
|
||||
int depth = 0;
|
||||
bool inString = false;
|
||||
bool escaped = false;
|
||||
|
||||
for (int i = startIndex; i < json.Length; i++)
|
||||
{
|
||||
char c = json[i];
|
||||
if (inString)
|
||||
{
|
||||
if (escaped)
|
||||
{
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (c == '\\')
|
||||
{
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (c == '"')
|
||||
{
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '"')
|
||||
{
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '[')
|
||||
{
|
||||
depth++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == ']')
|
||||
{
|
||||
depth--;
|
||||
if (depth == 0) return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private async Task RunPhasedRequestAsync()
|
||||
{
|
||||
if (_isThinking) return;
|
||||
@@ -518,6 +1081,7 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
|
||||
|
||||
try
|
||||
{
|
||||
PrepareMemoryContext();
|
||||
CompressHistoryIfNeeded();
|
||||
|
||||
var settings = WulaFallenEmpireMod.settings;
|
||||
@@ -801,6 +1365,7 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
|
||||
}
|
||||
|
||||
ParseResponse(reply);
|
||||
_ = ExtractAndUpdateMemoriesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1517,6 +2082,8 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori
|
||||
_inputText = "";
|
||||
|
||||
_history.Clear();
|
||||
_memoryContextCache = "";
|
||||
_memoryContextQuery = "";
|
||||
try
|
||||
{
|
||||
var historyManager = Find.World?.GetComponent<AIHistoryManager>();
|
||||
|
||||
Reference in New Issue
Block a user