290 lines
6.6 KiB
C#
290 lines
6.6 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Windows;
|
|
|
|
namespace AxCopilot.Services;
|
|
|
|
public class AgentMemoryService
|
|
{
|
|
private static readonly string MemoryDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "memory");
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNameCaseInsensitive = true
|
|
};
|
|
|
|
private readonly List<MemoryEntry> _entries = new List<MemoryEntry>();
|
|
|
|
private readonly object _lock = new object();
|
|
|
|
private string? _currentWorkFolder;
|
|
|
|
public int Count
|
|
{
|
|
get
|
|
{
|
|
lock (_lock)
|
|
{
|
|
return _entries.Count;
|
|
}
|
|
}
|
|
}
|
|
|
|
public IReadOnlyList<MemoryEntry> All
|
|
{
|
|
get
|
|
{
|
|
lock (_lock)
|
|
{
|
|
return _entries.ToList();
|
|
}
|
|
}
|
|
}
|
|
|
|
public void Load(string? workFolder)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
_entries.Clear();
|
|
_currentWorkFolder = workFolder;
|
|
string filePath = GetFilePath(null);
|
|
LoadFromFile(filePath);
|
|
if (!string.IsNullOrEmpty(workFolder))
|
|
{
|
|
string filePath2 = GetFilePath(workFolder);
|
|
LoadFromFile(filePath2);
|
|
}
|
|
}
|
|
}
|
|
|
|
public MemoryEntry Add(string type, string content, string? source = null, string? workFolder = null)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
MemoryEntry memoryEntry = _entries.FirstOrDefault((MemoryEntry e) => e.Type == type && CalculateSimilarity(e.Content, content) > 0.8);
|
|
if (memoryEntry != null)
|
|
{
|
|
memoryEntry.LastUsedAt = DateTime.Now;
|
|
memoryEntry.UseCount++;
|
|
Save();
|
|
return memoryEntry;
|
|
}
|
|
MemoryEntry memoryEntry2 = new MemoryEntry
|
|
{
|
|
Id = Guid.NewGuid().ToString("N").Substring(0, 12),
|
|
Type = type,
|
|
Content = content,
|
|
Source = (source ?? ""),
|
|
CreatedAt = DateTime.Now,
|
|
LastUsedAt = DateTime.Now,
|
|
UseCount = 1,
|
|
Relevance = 1.0,
|
|
WorkFolder = workFolder
|
|
};
|
|
_entries.Add(memoryEntry2);
|
|
Prune();
|
|
Save();
|
|
return memoryEntry2;
|
|
}
|
|
}
|
|
|
|
public List<MemoryEntry> GetRelevant(string query, int maxCount = 10)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(query))
|
|
{
|
|
return new List<MemoryEntry>();
|
|
}
|
|
lock (_lock)
|
|
{
|
|
HashSet<string> queryTokens = Tokenize(query);
|
|
return (from x in _entries.Select(delegate(MemoryEntry e)
|
|
{
|
|
HashSet<string> second = Tokenize(e.Content);
|
|
int num = queryTokens.Intersect<string>(second, StringComparer.OrdinalIgnoreCase).Count();
|
|
double num2 = ((queryTokens.Count > 0) ? ((double)num / (double)queryTokens.Count) : 0.0);
|
|
num2 += Math.Min((double)e.UseCount * 0.05, 0.3);
|
|
if ((DateTime.Now - e.LastUsedAt).TotalDays < 7.0)
|
|
{
|
|
num2 += 0.1;
|
|
}
|
|
return (Entry: e, Score: num2);
|
|
})
|
|
where x.Score > 0.1
|
|
orderby x.Score descending
|
|
select x).Take(maxCount).Select(delegate((MemoryEntry Entry, double Score) x)
|
|
{
|
|
x.Entry.Relevance = x.Score;
|
|
return x.Entry;
|
|
}).ToList();
|
|
}
|
|
}
|
|
|
|
public void Touch(string id)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
MemoryEntry memoryEntry = _entries.FirstOrDefault((MemoryEntry e) => e.Id == id);
|
|
if (memoryEntry != null)
|
|
{
|
|
memoryEntry.UseCount++;
|
|
memoryEntry.LastUsedAt = DateTime.Now;
|
|
Save();
|
|
}
|
|
}
|
|
}
|
|
|
|
public bool Remove(string id)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
bool flag = _entries.RemoveAll((MemoryEntry e) => e.Id == id) > 0;
|
|
if (flag)
|
|
{
|
|
Save();
|
|
}
|
|
return flag;
|
|
}
|
|
}
|
|
|
|
public void Clear()
|
|
{
|
|
lock (_lock)
|
|
{
|
|
_entries.Clear();
|
|
Save();
|
|
}
|
|
}
|
|
|
|
private void Prune()
|
|
{
|
|
int num = 100;
|
|
try
|
|
{
|
|
num = ((!(Application.Current is App app)) ? ((int?)null) : app.SettingsService?.Settings.Llm.MaxMemoryEntries) ?? 100;
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
if (_entries.Count <= num)
|
|
{
|
|
return;
|
|
}
|
|
List<MemoryEntry> list = (from e in _entries
|
|
orderby e.UseCount, e.LastUsedAt
|
|
select e).Take(_entries.Count - num).ToList();
|
|
foreach (MemoryEntry item in list)
|
|
{
|
|
_entries.Remove(item);
|
|
}
|
|
}
|
|
|
|
private void Save()
|
|
{
|
|
Directory.CreateDirectory(MemoryDir);
|
|
List<MemoryEntry> entries = _entries.Where((MemoryEntry e) => e.WorkFolder == null).ToList();
|
|
SaveToFile(GetFilePath(null), entries);
|
|
if (!string.IsNullOrEmpty(_currentWorkFolder))
|
|
{
|
|
List<MemoryEntry> entries2 = _entries.Where((MemoryEntry e) => e.WorkFolder != null).ToList();
|
|
SaveToFile(GetFilePath(_currentWorkFolder), entries2);
|
|
}
|
|
}
|
|
|
|
private void SaveToFile(string path, List<MemoryEntry> entries)
|
|
{
|
|
try
|
|
{
|
|
string plainText = JsonSerializer.Serialize(entries, JsonOptions);
|
|
string contents = CryptoService.PortableEncrypt(plainText);
|
|
string text = path + ".tmp";
|
|
File.WriteAllText(text, contents);
|
|
File.Move(text, path, overwrite: true);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
LogService.Warn("메모리 저장 실패: " + ex.Message);
|
|
}
|
|
}
|
|
|
|
private void LoadFromFile(string path)
|
|
{
|
|
if (!File.Exists(path))
|
|
{
|
|
return;
|
|
}
|
|
try
|
|
{
|
|
string @base = File.ReadAllText(path);
|
|
string text = CryptoService.PortableDecrypt(@base);
|
|
if (!string.IsNullOrEmpty(text))
|
|
{
|
|
List<MemoryEntry> list = JsonSerializer.Deserialize<List<MemoryEntry>>(text, JsonOptions);
|
|
if (list != null)
|
|
{
|
|
_entries.AddRange(list);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
LogService.Warn("메모리 로드 실패 (" + path + "): " + ex.Message);
|
|
}
|
|
}
|
|
|
|
private static string GetFilePath(string? workFolder)
|
|
{
|
|
if (string.IsNullOrEmpty(workFolder))
|
|
{
|
|
return Path.Combine(MemoryDir, "_global.dat");
|
|
}
|
|
string text = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(workFolder.ToLowerInvariant()))).Substring(0, 16);
|
|
return Path.Combine(MemoryDir, text + ".dat");
|
|
}
|
|
|
|
private static double CalculateSimilarity(string a, string b)
|
|
{
|
|
HashSet<string> hashSet = Tokenize(a);
|
|
HashSet<string> hashSet2 = Tokenize(b);
|
|
if (hashSet.Count == 0 || hashSet2.Count == 0)
|
|
{
|
|
return 0.0;
|
|
}
|
|
int num = hashSet.Intersect<string>(hashSet2, StringComparer.OrdinalIgnoreCase).Count();
|
|
int num2 = hashSet.Union<string>(hashSet2, StringComparer.OrdinalIgnoreCase).Count();
|
|
return (num2 > 0) ? ((double)num / (double)num2) : 0.0;
|
|
}
|
|
|
|
private static HashSet<string> Tokenize(string text)
|
|
{
|
|
HashSet<string> hashSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
StringBuilder stringBuilder = new StringBuilder();
|
|
foreach (char c in text)
|
|
{
|
|
if (char.IsLetterOrDigit(c) || c == '_')
|
|
{
|
|
stringBuilder.Append(c);
|
|
}
|
|
else if (stringBuilder.Length > 1)
|
|
{
|
|
hashSet.Add(stringBuilder.ToString());
|
|
stringBuilder.Clear();
|
|
}
|
|
else
|
|
{
|
|
stringBuilder.Clear();
|
|
}
|
|
}
|
|
if (stringBuilder.Length > 1)
|
|
{
|
|
hashSet.Add(stringBuilder.ToString());
|
|
}
|
|
return hashSet;
|
|
}
|
|
}
|