using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Text.Json;using System.Threading.Tasks;using Agent.Core.Ollama;namespace Agent.Core.Memory;public class MemoryManager{ private readonly string _memoryFilePath; private readonly OllamaClient _ollamaClient; private readonly List<MemoryLeaf> _leaves = new(); private readonly object _lock = new(); public MemoryManager(string memoryFilePath, OllamaClient ollamaClient) { _memoryFilePath = memoryFilePath; _ollamaClient = ollamaClient; Load(); } public IReadOnlyList<MemoryLeaf> Leaves { get { lock (_lock) { return _leaves.ToList(); } } } /// <summary> /// Loads memory from the nested JSON structure. /// </summary> public void Load() { lock (_lock) { _leaves.Clear(); if (!File.Exists(_memoryFilePath)) return; try { string json = File.ReadAllText(_memoryFilePath); if (string.IsNullOrWhiteSpace(json)) return; using var doc = JsonDocument.Parse(json); ExtractLeaves("", doc.RootElement, _leaves); } catch (Exception ex) { _ollamaClient.Logger.WriteLine("Memory", $"Failed to load memory file: {ex.Message}", LogColors.Orange); } } } /// <summary> /// Saves memory to a nested JSON structure. /// </summary> public void Save() { lock (_lock) { try { var hierarchy = BuildHierarchy(_leaves); string json = JsonSerializer.Serialize(hierarchy, new JsonSerializerOptions { WriteIndented = true }); string? dir = Path.GetDirectoryName(_memoryFilePath); if (dir is not null && !Directory.Exists(dir)) { Directory.CreateDirectory(dir); } File.WriteAllText(_memoryFilePath, json); } catch (Exception ex) { _ollamaClient.Logger.WriteLine("Memory", $"Failed to save memory file: {ex.Message}", LogColors.Orange); } } } /// <summary> /// Stores content under path, generating its vector embedding. /// </summary> public async Task StoreAsync(string path, string content) { if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Path cannot be empty.", nameof(path)); // Clean path (trim, normalize slashes) path = string.Join("/", path.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim())); _ollamaClient.Logger.WriteLine("Memory", $"Storing to memory path '{path}'...", LogColors.Cyan); // Generate embedding for "Path: {path}\nContent: {content}" string textToEmbed = $"Path: {path}\nContent: {content}"; float[] embedding = await _ollamaClient.GetEmbeddingAsync(textToEmbed); lock (_lock) { // Remove existing leaf at this path if it exists _leaves.RemoveAll(l => l.Path.Equals(path, StringComparison.OrdinalIgnoreCase)); _leaves.Add(new MemoryLeaf { Path = path, Value = content, Embedding = embedding }); Save(); } _ollamaClient.Logger.WriteLine("Memory", $"Successfully stored memory under '{path}'.", LogColors.Green); } /// <summary> /// Performs semantic vector search on memory leaves. /// </summary> public async Task<List<MemoryLeaf>> RetrieveAsync(string query, int topN = 3) { if (string.IsNullOrWhiteSpace(query)) return new List<MemoryLeaf>(); float[] queryEmbedding; try { queryEmbedding = await _ollamaClient.GetEmbeddingAsync(query); } catch (Exception ex) { _ollamaClient.Logger.WriteLine("Memory", $"Embedding generation failed during query: {ex.Message}", LogColors.Orange); // Fallback: search by path string similarity or return empty return new List<MemoryLeaf>(); } lock (_lock) { var matches = new List<(MemoryLeaf Leaf, float Similarity)>(); foreach (var leaf in _leaves) { float sim = CosineSimilarity(queryEmbedding, leaf.Embedding); matches.Add((leaf, sim)); } return matches .OrderByDescending(m => m.Similarity) .Take(topN) .Select(m => m.Leaf) .ToList(); } } /// <summary> /// Query exact path. /// </summary> public string? QueryPath(string path) { path = string.Join("/", path.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim())); lock (_lock) { var match = _leaves.FirstOrDefault(l => l.Path.Equals(path, StringComparison.OrdinalIgnoreCase)); return match?.Value; } } public static float CosineSimilarity(float[] vectorA, float[] vectorB) { if (vectorA.Length != vectorB.Length || vectorA.Length == 0) return 0f; float dotProduct = 0f; float normA = 0f; float normB = 0f; for (int i = 0; i < vectorA.Length; i++) { dotProduct += vectorA[i] * vectorB[i]; normA += vectorA[i] * vectorA[i]; normB += vectorB[i] * vectorB[i]; } if (normA == 0f || normB == 0f) return 0f; return dotProduct / (MathF.Sqrt(normA) * MathF.Sqrt(normB)); } private static void ExtractLeaves(string currentPath, JsonElement element, List<MemoryLeaf> leaves) { if (element.ValueKind == JsonValueKind.Object) { // Check if this object contains "value" and "embedding" properties bool hasValue = element.TryGetProperty("value", out var valProp); bool hasEmbedding = element.TryGetProperty("embedding", out var embedProp); if (hasValue && hasEmbedding) { var leaf = new MemoryLeaf { Path = currentPath, Value = valProp.GetString() ?? "", Embedding = DeserializeEmbedding(embedProp) }; leaves.Add(leaf); // If it ALSO has other properties (it's both a leaf and a branch), // continue to recurse into those other properties. foreach (var prop in element.EnumerateObject()) { if (prop.Name != "value" && prop.Name != "embedding") { string nextPath = string.IsNullOrEmpty(currentPath) ? prop.Name : $"{currentPath}/{prop.Name}"; ExtractLeaves(nextPath, prop.Value, leaves); } } } else { // This is a branch foreach (var prop in element.EnumerateObject()) { string nextPath = string.IsNullOrEmpty(currentPath) ? prop.Name : $"{currentPath}/{prop.Name}"; ExtractLeaves(nextPath, prop.Value, leaves); } } } } private static float[] DeserializeEmbedding(JsonElement element) { if (element.ValueKind != JsonValueKind.Array) return Array.Empty<float>(); var list = new List<float>(); foreach (var val in element.EnumerateArray()) { if (val.TryGetSingle(out float f)) { list.Add(f); } } return list.ToArray(); } private static Dictionary<string, object> BuildHierarchy(List<MemoryLeaf> leaves) { var root = new Dictionary<string, object>(); foreach (var leaf in leaves) { var parts = leaf.Path.Split('/'); var current = root; for (int i = 0; i < parts.Length - 1; i++) { string part = parts[i]; if (!current.TryGetValue(part, out object? obj) || obj is not Dictionary<string, object> nextDict) { nextDict = new Dictionary<string, object>(); current[part] = nextDict; } current = nextDict; } string leafKey = parts[^1]; current[leafKey] = new Dictionary<string, object> { { "value", leaf.Value }, { "embedding", leaf.Embedding } }; } return root; }}
Documentation
Agent-Core (Version 1)
Shared library for Genesis agents: logging, Ollama client, and chat history.
Each .cs file lives in its own folder with a README.md (same layout as the Lilith agent tree). Entry point: Core/Core.cs.
Build via the Version 1 solution or python ../Build-v1.py.