Memory/MemoryManager.cscsharp

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.

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;    }}