using Nexus.Api.Models; namespace Nexus.Api.Services; public sealed class MemoryService( IOpenClawAgentConfigurationService configuration) : IMemoryService { private const string MemoryDirectory = "memory"; public async Task> GetAllAsync( string agentId, CancellationToken cancellationToken = default) { var normalizedAgentId = NormalizeAgentId(agentId); var result = new List(); var agentFiles = await configuration.GetAgentFilesAsync( normalizedAgentId, cancellationToken); var longTerm = agentFiles.Files.SingleOrDefault(file => string.Equals(file.Name, "MEMORY.md", StringComparison.OrdinalIgnoreCase)); if (longTerm is { Missing: false }) { result.Add(new MemoryFileInfo( "MEMORY.md", "MEMORY.md", longTerm.Size ?? 0, (longTerm.UpdatedAt ?? agentFiles.CheckedAt).UtcDateTime, normalizedAgentId, "MEMORY.md")); } var workspace = await TryGetMemoryWorkspaceAsync( normalizedAgentId, cancellationToken); if (workspace is not null) { result.AddRange(workspace.Entries .Where(OpenClawContentReadHelpers.IsMarkdownFile) .OrderByDescending( entry => entry.Name, StringComparer.OrdinalIgnoreCase) .Select(entry => new MemoryFileInfo( entry.Name, OpenClawContentReadHelpers.LegacyPath( entry.Path, MemoryDirectory), entry.Size ?? 0, (entry.UpdatedAt ?? workspace.CheckedAt).UtcDateTime, normalizedAgentId, entry.Path))); } return result; } public async Task> SearchAsync( string query, string agentId, CancellationToken cancellationToken = default) { var normalizedAgentId = NormalizeAgentId(agentId); var normalizedQuery = query.Trim(); var candidates = new List(); var agentFiles = await configuration.GetAgentFilesAsync( normalizedAgentId, cancellationToken); if (agentFiles.Files.Any(file => string.Equals(file.Name, "MEMORY.md", StringComparison.OrdinalIgnoreCase) && !file.Missing && (file.Size is null || file.Size <= OpenClawContentReadHelpers.MaxContentBytes))) { candidates.Add(new MemoryCandidate( "MEMORY.md", "MEMORY.md", "MEMORY.md", true, 0)); } var workspace = await TryGetMemoryWorkspaceAsync( normalizedAgentId, cancellationToken); if (workspace is not null) { candidates.AddRange(workspace.Entries .Where(OpenClawContentReadHelpers.IsMarkdownFile) .Select(entry => new MemoryCandidate( entry.Name, OpenClawContentReadHelpers.LegacyPath( entry.Path, MemoryDirectory), entry.Path, false, entry.Size ?? 0))); } return await OpenClawContentReadHelpers.SelectBoundedAsync< MemoryCandidate, MemorySearchResult>( candidates, async (candidate, token) => { string? content; long size; if (candidate.AgentFile) { var file = await configuration.GetAgentFileAsync( normalizedAgentId, "MEMORY.md", token); content = file.Missing || file.Content is null || file.Size > OpenClawContentReadHelpers.MaxContentBytes || System.Text.Encoding.UTF8.GetByteCount( file.Content) > OpenClawContentReadHelpers.MaxContentBytes ? null : file.Content; size = file.Size ?? 0; } else { var file = await configuration.GetWorkspaceFileAsync( normalizedAgentId, candidate.WorkspacePath, token); content = OpenClawContentReadHelpers.ReadText(file); size = file.Size; } if (content is null) return null; var index = content.IndexOf( normalizedQuery, StringComparison.OrdinalIgnoreCase); if (index < 0) return null; var start = Math.Max(0, index - 60); var length = Math.Min(200, content.Length - start); var excerpt = (start > 0 ? "…" : string.Empty) + content.Substring(start, length) + (start + length < content.Length ? "…" : string.Empty); return new MemorySearchResult( candidate.Name, candidate.LegacyPath, excerpt, size, normalizedAgentId, candidate.WorkspacePath); }, cancellationToken); } public async Task GetFileAsync( string name, string agentId, CancellationToken cancellationToken = default) { var normalizedAgentId = NormalizeAgentId(agentId); if (string.Equals(name, "MEMORY.md", StringComparison.OrdinalIgnoreCase)) { var file = await configuration.GetAgentFileAsync( normalizedAgentId, "MEMORY.md", cancellationToken); if (file.Missing || file.Content is null || file.Size > OpenClawContentReadHelpers.MaxContentBytes || System.Text.Encoding.UTF8.GetByteCount(file.Content) > OpenClawContentReadHelpers.MaxContentBytes) return null; return new MemoryFileContent( "MEMORY.md", "MEMORY.md", file.Content, file.Size ?? 0, (file.UpdatedAt ?? file.CheckedAt).UtcDateTime, normalizedAgentId, "MEMORY.md"); } if (!OpenClawContentReadHelpers.IsSafeFileName(name)) return null; var workspacePath = $"{MemoryDirectory}/{name}"; try { var file = await configuration.GetWorkspaceFileAsync( normalizedAgentId, workspacePath, cancellationToken); var content = OpenClawContentReadHelpers.ReadText(file); if (content is null) return null; return new MemoryFileContent( file.Name, name, content, file.Size, (file.UpdatedAt ?? file.CheckedAt).UtcDateTime, normalizedAgentId, file.Path); } catch (OpenClawGatewayRpcException exception) when (OpenClawContentReadHelpers.IsNotFound(exception)) { return null; } } private static string NormalizeAgentId(string? agentId) => string.IsNullOrWhiteSpace(agentId) ? "iris" : agentId.Trim().ToLowerInvariant(); private async Task TryGetMemoryWorkspaceAsync( string agentId, CancellationToken cancellationToken) { try { return await configuration.GetWorkspaceAsync( agentId, MemoryDirectory, 0, OpenClawContentReadHelpers.MaxFiles, cancellationToken); } catch (OpenClawGatewayRpcException exception) when (OpenClawContentReadHelpers.IsNotFound(exception)) { return null; } } private sealed record MemoryCandidate( string Name, string LegacyPath, string WorkspacePath, bool AgentFile, long Size); }