- MemoryService: Verwaltet data/agents/{agentId}/ Verzeichnisse
- MEMORY.md als Haupt-Memory-File
- memory/ Unterordner für Tages-Logs und thematische Notizen
- Sandbox-Prüfung verhindert Zugriff außerhalb des Agent-Dirs
- Memory-Tools für den Agent:
- memory_read: Liest Memory-Dateien
- memory_write: Schreibt/überschreibt oder hängt an (append)
- memory_list: Listet alle Memory-Dateien auf
- SystemPromptBuilder: Memory-Abschnitt mit Regeln und aktuellem
MEMORY.md-Inhalt wird in den System-Prompt injiziert
- ToolService/AnthropicService: Memory-Tools werden automatisch
eingebunden wenn MemoryService und AgentId verfügbar sind
- TODO.md: Embedding-basierte Suche und Subagents als Features
399 lines
14 KiB
C#
399 lines
14 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Diagnostics;
|
||
using System.Linq;
|
||
using System.Text.Json;
|
||
using System.Threading.Tasks;
|
||
using Gott.Services;
|
||
using Microsoft.AspNetCore.Builder;
|
||
using Microsoft.AspNetCore.Http;
|
||
using Microsoft.AspNetCore.Routing;
|
||
|
||
namespace Gott.Endpoints;
|
||
|
||
public static class SessionEndpoint
|
||
{
|
||
public static void MapSessionEndpoints(this IEndpointRouteBuilder app)
|
||
{
|
||
app.MapGet("/api/sessions", HandleList);
|
||
app.MapGet("/api/sessions/{sessionId}", HandleGet);
|
||
app.MapPost("/api/sessions", HandleCreate);
|
||
app.MapPut("/api/sessions/{sessionId}", HandleUpdate);
|
||
app.MapDelete("/api/sessions/{sessionId}", HandleDelete);
|
||
app.MapGet("/api/sessions/{sessionId}/system-prompt", HandleSystemPrompt);
|
||
app.MapGet("/api/sessions/{sessionId}/streaming", HandleStreaming);
|
||
app.MapPost("/api/sessions/{sessionId}/exec", HandleExec);
|
||
app.MapGet("/api/sessions/{sessionId}/git-status", HandleGitStatus);
|
||
}
|
||
|
||
private static async Task HandleList(HttpContext ctx, DatabaseService db)
|
||
{
|
||
var sessions = await db.GetSessionsWithStats();
|
||
await ctx.Response.WriteAsJsonAsync(sessions);
|
||
}
|
||
|
||
private static async Task HandleGet(HttpContext ctx, DatabaseService db)
|
||
{
|
||
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
|
||
if (string.IsNullOrEmpty(sessionId))
|
||
{
|
||
ctx.Response.StatusCode = 400;
|
||
return;
|
||
}
|
||
var session = await db.GetSession(sessionId);
|
||
if (session == null)
|
||
{
|
||
ctx.Response.StatusCode = 404;
|
||
return;
|
||
}
|
||
await ctx.Response.WriteAsJsonAsync(session);
|
||
}
|
||
|
||
private static async Task HandleCreate(HttpContext ctx, DatabaseService db, ImageService imageService)
|
||
{
|
||
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
|
||
|
||
var name = body.TryGetProperty("name", out var n) ? n.GetString() : "Neue Session";
|
||
var description = body.TryGetProperty("description", out var d) ? d.GetString() : null;
|
||
var imageRaw = body.TryGetProperty("image", out var img) ? img.GetString() : null;
|
||
var image = imageService.ResizeDataUrl(imageRaw);
|
||
var agentId = body.TryGetProperty("agentId", out var a) ? a.GetString() : null;
|
||
var workspace = body.TryGetProperty("workspace", out var w) ? w.GetString() : null;
|
||
var context = body.TryGetProperty("context", out var ctxEl) ? ctxEl.GetString() : null;
|
||
var customCommands = body.TryGetProperty("customCommands", out var cc) && cc.ValueKind != JsonValueKind.Null ? cc.GetRawText() : null;
|
||
|
||
var session = await db.CreateSession(name, description, image, agentId, workspace, context, customCommands);
|
||
await ctx.Response.WriteAsJsonAsync(session);
|
||
}
|
||
|
||
private static async Task HandleUpdate(HttpContext ctx, DatabaseService db, ImageService imageService)
|
||
{
|
||
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
|
||
if (string.IsNullOrEmpty(sessionId))
|
||
{
|
||
ctx.Response.StatusCode = 400;
|
||
return;
|
||
}
|
||
|
||
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
|
||
await db.UpdateSession(sessionId, body, imageService);
|
||
|
||
var session = await db.GetSession(sessionId);
|
||
await ctx.Response.WriteAsJsonAsync(session);
|
||
}
|
||
|
||
private static async Task HandleDelete(HttpContext ctx, DatabaseService db)
|
||
{
|
||
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
|
||
if (string.IsNullOrEmpty(sessionId)) { ctx.Response.StatusCode = 400; return; }
|
||
|
||
await db.DeleteSession(sessionId);
|
||
ctx.Response.StatusCode = 204;
|
||
}
|
||
|
||
private static async Task HandleSystemPrompt(HttpContext ctx, DatabaseService db, MemoryService memoryService)
|
||
{
|
||
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
|
||
if (string.IsNullOrEmpty(sessionId))
|
||
{
|
||
ctx.Response.StatusCode = 400;
|
||
return;
|
||
}
|
||
|
||
var session = await db.GetSession(sessionId);
|
||
if (session == null)
|
||
{
|
||
ctx.Response.StatusCode = 404;
|
||
return;
|
||
}
|
||
|
||
if (string.IsNullOrEmpty(session.AgentId))
|
||
{
|
||
ctx.Response.StatusCode = 400;
|
||
await ctx.Response.WriteAsJsonAsync(new { error = "Kein Agent ausgewählt" });
|
||
return;
|
||
}
|
||
|
||
var agent = await db.GetAgent(session.AgentId);
|
||
if (agent == null)
|
||
{
|
||
ctx.Response.StatusCode = 400;
|
||
await ctx.Response.WriteAsJsonAsync(new { error = "Agent nicht gefunden" });
|
||
return;
|
||
}
|
||
|
||
var markedMessages = await db.GetMarkedMessages(sessionId);
|
||
var systemPrompt = SystemPromptBuilder.Build(agent, session, markedMessages, memoryService);
|
||
|
||
ctx.Response.ContentType = "text/plain; charset=utf-8";
|
||
await ctx.Response.WriteAsync(systemPrompt);
|
||
}
|
||
|
||
private static async Task HandleStreaming(HttpContext ctx, StreamingTracker streamingTracker)
|
||
{
|
||
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
|
||
if (string.IsNullOrEmpty(sessionId))
|
||
{
|
||
ctx.Response.StatusCode = 400;
|
||
return;
|
||
}
|
||
|
||
var isStreaming = streamingTracker.IsStreaming(sessionId);
|
||
var chainId = streamingTracker.GetChainId(sessionId);
|
||
await ctx.Response.WriteAsJsonAsync(new { streaming = isStreaming, chainId });
|
||
}
|
||
|
||
private static async Task HandleExec(HttpContext ctx, DatabaseService db)
|
||
{
|
||
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
|
||
if (string.IsNullOrEmpty(sessionId))
|
||
{
|
||
ctx.Response.StatusCode = 400;
|
||
return;
|
||
}
|
||
|
||
var session = await db.GetSession(sessionId);
|
||
if (session == null)
|
||
{
|
||
ctx.Response.StatusCode = 404;
|
||
return;
|
||
}
|
||
|
||
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
|
||
var command = body.TryGetProperty("command", out var c) ? c.GetString() : null;
|
||
var mode = body.TryGetProperty("mode", out var m) ? m.GetString() ?? "background" : "background";
|
||
|
||
if (string.IsNullOrWhiteSpace(command))
|
||
{
|
||
ctx.Response.StatusCode = 400;
|
||
await ctx.Response.WriteAsJsonAsync(new { error = "Kein Befehl angegeben" });
|
||
return;
|
||
}
|
||
|
||
var workingDir = !string.IsNullOrWhiteSpace(session.Workspace) ? session.Workspace : "/tmp";
|
||
|
||
if (mode == "foreground")
|
||
{
|
||
// Synchron ausführen, auf Ergebnis warten
|
||
try
|
||
{
|
||
var psi = new ProcessStartInfo
|
||
{
|
||
FileName = "/bin/bash",
|
||
WorkingDirectory = workingDir,
|
||
RedirectStandardInput = true,
|
||
RedirectStandardOutput = true,
|
||
RedirectStandardError = true,
|
||
UseShellExecute = false,
|
||
CreateNoWindow = true
|
||
};
|
||
|
||
using var proc = System.Diagnostics.Process.Start(psi);
|
||
if (proc == null)
|
||
{
|
||
ctx.Response.StatusCode = 500;
|
||
await ctx.Response.WriteAsJsonAsync(new { error = "Prozess konnte nicht gestartet werden" });
|
||
return;
|
||
}
|
||
|
||
await proc.StandardInput.WriteAsync(command);
|
||
proc.StandardInput.Close();
|
||
|
||
using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||
try
|
||
{
|
||
await proc.WaitForExitAsync(cts.Token);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
proc.Kill(entireProcessTree: true);
|
||
await ctx.Response.WriteAsJsonAsync(new { status = "timeout", output = "Timeout nach 30 Sekunden", exitCode = -1 });
|
||
return;
|
||
}
|
||
|
||
var stdout = await proc.StandardOutput.ReadToEndAsync();
|
||
var stderr = await proc.StandardError.ReadToEndAsync();
|
||
|
||
var output = new System.Text.StringBuilder();
|
||
if (!string.IsNullOrEmpty(stdout)) output.Append(stdout);
|
||
if (!string.IsNullOrEmpty(stderr))
|
||
{
|
||
if (output.Length > 0) output.Append("\n--- stderr ---\n");
|
||
output.Append(stderr);
|
||
}
|
||
|
||
await ctx.Response.WriteAsJsonAsync(new { status = "completed", output = output.ToString(), exitCode = proc.ExitCode });
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
ctx.Response.StatusCode = 500;
|
||
await ctx.Response.WriteAsJsonAsync(new { error = ex.Message });
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// Fire-and-forget via systemd-run
|
||
try
|
||
{
|
||
var scriptPath = $"/tmp/gott-exec-{Guid.NewGuid():N}.sh";
|
||
await System.IO.File.WriteAllTextAsync(scriptPath, $"#!/bin/bash\ncd {workingDir}\n{command}\nrm -f {scriptPath}\n");
|
||
|
||
var psi = new ProcessStartInfo
|
||
{
|
||
FileName = "systemd-run",
|
||
Arguments = $"--scope --quiet bash {scriptPath}",
|
||
WorkingDirectory = workingDir,
|
||
UseShellExecute = false,
|
||
RedirectStandardOutput = true,
|
||
RedirectStandardError = true,
|
||
CreateNoWindow = true
|
||
};
|
||
|
||
var process = Process.Start(psi);
|
||
if (process != null)
|
||
{
|
||
process.WaitForExit(500);
|
||
}
|
||
|
||
ctx.Response.StatusCode = 202;
|
||
await ctx.Response.WriteAsJsonAsync(new { status = "started", command = command });
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
ctx.Response.StatusCode = 500;
|
||
await ctx.Response.WriteAsJsonAsync(new { error = ex.Message });
|
||
}
|
||
}
|
||
}
|
||
|
||
private static async Task HandleGitStatus(HttpContext ctx, DatabaseService db)
|
||
{
|
||
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
|
||
if (string.IsNullOrEmpty(sessionId))
|
||
{
|
||
ctx.Response.StatusCode = 400;
|
||
return;
|
||
}
|
||
|
||
var session = await db.GetSession(sessionId);
|
||
if (session == null)
|
||
{
|
||
ctx.Response.StatusCode = 404;
|
||
return;
|
||
}
|
||
|
||
var workingDir = !string.IsNullOrWhiteSpace(session.Workspace) ? session.Workspace : null;
|
||
if (string.IsNullOrEmpty(workingDir))
|
||
{
|
||
ctx.Response.StatusCode = 400;
|
||
await ctx.Response.WriteAsJsonAsync(new { error = "Kein Workspace konfiguriert" });
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
var psi = new ProcessStartInfo
|
||
{
|
||
FileName = "git",
|
||
Arguments = "status --porcelain",
|
||
WorkingDirectory = workingDir,
|
||
RedirectStandardOutput = true,
|
||
RedirectStandardError = true,
|
||
UseShellExecute = false,
|
||
CreateNoWindow = true
|
||
};
|
||
|
||
using var proc = Process.Start(psi);
|
||
if (proc == null)
|
||
{
|
||
ctx.Response.StatusCode = 500;
|
||
await ctx.Response.WriteAsJsonAsync(new { error = "Git-Prozess konnte nicht gestartet werden" });
|
||
return;
|
||
}
|
||
|
||
var stdout = await proc.StandardOutput.ReadToEndAsync();
|
||
var stderr = await proc.StandardError.ReadToEndAsync();
|
||
await proc.WaitForExitAsync();
|
||
|
||
if (proc.ExitCode != 0)
|
||
{
|
||
ctx.Response.StatusCode = 500;
|
||
await ctx.Response.WriteAsJsonAsync(new { error = stderr.Trim() });
|
||
return;
|
||
}
|
||
|
||
var add = new List<string>();
|
||
var edit = new List<string>();
|
||
var delete = new List<string>();
|
||
|
||
var lines = stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||
foreach (var line in lines)
|
||
{
|
||
if (line.Length < 4) continue;
|
||
|
||
var x = line[0]; // index status
|
||
var y = line[1]; // working tree status
|
||
var file = line.Substring(3);
|
||
|
||
// Bei Renames: "R old -> new" => nur den neuen Namen nehmen
|
||
var arrowIdx = file.IndexOf(" -> ", StringComparison.Ordinal);
|
||
if (arrowIdx >= 0)
|
||
file = file.Substring(arrowIdx + 4);
|
||
|
||
if (x == '?' && y == '?')
|
||
{
|
||
add.Add(file);
|
||
}
|
||
else if (x == 'D' || y == 'D')
|
||
{
|
||
delete.Add(file);
|
||
}
|
||
else if (x == 'A')
|
||
{
|
||
add.Add(file);
|
||
}
|
||
else
|
||
{
|
||
edit.Add(file);
|
||
}
|
||
}
|
||
|
||
// Outgoing commits zählen (nur wenn Remote existiert)
|
||
int outgoing = 0;
|
||
try
|
||
{
|
||
var outPsi = new ProcessStartInfo
|
||
{
|
||
FileName = "git",
|
||
Arguments = "rev-list --count @{u}..HEAD",
|
||
WorkingDirectory = workingDir,
|
||
RedirectStandardOutput = true,
|
||
RedirectStandardError = true,
|
||
UseShellExecute = false,
|
||
CreateNoWindow = true
|
||
};
|
||
|
||
using var outProc = Process.Start(outPsi);
|
||
if (outProc != null)
|
||
{
|
||
var outResult = (await outProc.StandardOutput.ReadToEndAsync()).Trim();
|
||
await outProc.WaitForExitAsync();
|
||
if (outProc.ExitCode == 0 && int.TryParse(outResult, out var count))
|
||
outgoing = count;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// Kein Remote oder kein Upstream – outgoing bleibt 0
|
||
}
|
||
|
||
await ctx.Response.WriteAsJsonAsync(new { add, edit, delete, outgoing });
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
ctx.Response.StatusCode = 500;
|
||
await ctx.Response.WriteAsJsonAsync(new { error = ex.Message });
|
||
}
|
||
}
|
||
}
|