feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -1,100 +1,243 @@
|
||||
using Nexus.Api.Helpers;
|
||||
using Nexus.Api.Models;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class MemoryService : IMemoryService
|
||||
public sealed class MemoryService(
|
||||
IOpenClawAgentConfigurationService configuration) : IMemoryService
|
||||
{
|
||||
private const string BasePath = "/mnt/workspace-iris/memory";
|
||||
private const string LongTermPath = "/mnt/workspace-iris/MEMORY.md";
|
||||
private const int MaxFileSize = 1_000_000;
|
||||
private const int MaxFiles = 50;
|
||||
private const string MemoryDirectory = "memory";
|
||||
|
||||
public Task<IReadOnlyList<MemoryFileInfo>> GetAllAsync()
|
||||
public async Task<IReadOnlyList<MemoryFileInfo>> GetAllAsync(
|
||||
string agentId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var files = new List<MemoryFileInfo>();
|
||||
|
||||
if (File.Exists(LongTermPath))
|
||||
var normalizedAgentId = NormalizeAgentId(agentId);
|
||||
var result = new List<MemoryFileInfo>();
|
||||
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 })
|
||||
{
|
||||
var fi = new FileInfo(LongTermPath);
|
||||
files.Add(new MemoryFileInfo("MEMORY.md", "MEMORY.md", fi.Length, fi.LastWriteTimeUtc));
|
||||
result.Add(new MemoryFileInfo(
|
||||
"MEMORY.md",
|
||||
"MEMORY.md",
|
||||
longTerm.Size ?? 0,
|
||||
(longTerm.UpdatedAt ?? agentFiles.CheckedAt).UtcDateTime,
|
||||
normalizedAgentId,
|
||||
"MEMORY.md"));
|
||||
}
|
||||
|
||||
if (Directory.Exists(BasePath))
|
||||
var workspace = await TryGetMemoryWorkspaceAsync(
|
||||
normalizedAgentId,
|
||||
cancellationToken);
|
||||
if (workspace is not null)
|
||||
{
|
||||
var memFiles = Directory.GetFiles(BasePath, "*.md")
|
||||
.Select(f => new FileInfo(f))
|
||||
.OrderByDescending(f => f.Name)
|
||||
.Select(f => new MemoryFileInfo(
|
||||
f.Name,
|
||||
f.FullName.Replace(BasePath, "").TrimStart('/'),
|
||||
f.Length,
|
||||
f.LastWriteTimeUtc));
|
||||
files.AddRange(memFiles);
|
||||
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 Task.FromResult<IReadOnlyList<MemoryFileInfo>>(files);
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MemorySearchResult>> SearchAsync(string query)
|
||||
public async Task<IReadOnlyList<MemorySearchResult>> SearchAsync(
|
||||
string query,
|
||||
string agentId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new List<MemorySearchResult>();
|
||||
|
||||
async Task SearchDir(string dir)
|
||||
var normalizedAgentId = NormalizeAgentId(agentId);
|
||||
var normalizedQuery = query.Trim();
|
||||
var candidates = new List<MemoryCandidate>();
|
||||
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)))
|
||||
{
|
||||
if (!Directory.Exists(dir)) return;
|
||||
foreach (var file in Directory.GetFiles(dir, "*.md").Take(MaxFiles))
|
||||
{
|
||||
var fi = new FileInfo(file);
|
||||
if (fi.Length > MaxFileSize) continue;
|
||||
var content = await File.ReadAllTextAsync(file);
|
||||
if (!content.Contains(query, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
candidates.Add(new MemoryCandidate(
|
||||
"MEMORY.md",
|
||||
"MEMORY.md",
|
||||
"MEMORY.md",
|
||||
true,
|
||||
0));
|
||||
}
|
||||
|
||||
var idx = content.IndexOf(query, StringComparison.OrdinalIgnoreCase);
|
||||
var start = Math.Max(0, idx - 60);
|
||||
var excerpt = (start > 0 ? "…" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "…";
|
||||
results.Add(new MemorySearchResult(
|
||||
Path.GetFileName(file),
|
||||
file.Replace(BasePath, "").TrimStart('/'),
|
||||
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,
|
||||
fi.Length));
|
||||
}
|
||||
}
|
||||
|
||||
await SearchDir(BasePath);
|
||||
|
||||
if (File.Exists(LongTermPath))
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(LongTermPath);
|
||||
if (content.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var idx = content.IndexOf(query, StringComparison.OrdinalIgnoreCase);
|
||||
var start = Math.Max(0, idx - 60);
|
||||
var excerpt = (start > 0 ? "…" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "…";
|
||||
results.Insert(0, new MemorySearchResult("MEMORY.md", "MEMORY.md", excerpt, content.Length));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
size,
|
||||
normalizedAgentId,
|
||||
candidate.WorkspacePath);
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MemoryFileContent?> GetFileAsync(string name)
|
||||
public async Task<MemoryFileContent?> GetFileAsync(
|
||||
string name,
|
||||
string agentId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string? filePath;
|
||||
|
||||
if (name.Equals("MEMORY.md", StringComparison.OrdinalIgnoreCase))
|
||||
var normalizedAgentId = NormalizeAgentId(agentId);
|
||||
if (string.Equals(name, "MEMORY.md", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
filePath = LongTermPath;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!PathSecurityHelper.TryResolveSafePath(BasePath, name, out filePath))
|
||||
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 (!File.Exists(filePath!))
|
||||
if (!OpenClawContentReadHelpers.IsSafeFileName(name))
|
||||
return null;
|
||||
|
||||
var content = await File.ReadAllTextAsync(filePath!);
|
||||
return new MemoryFileContent(name, name, content, content.Length, File.GetLastWriteTimeUtc(filePath!));
|
||||
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<OpenClawWorkspaceCollectionDto?>
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user