using ClawdDotNet.Core.Files; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Organisation; using System.Collections.Concurrent; using System.Text; using System.Text.Json; using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Tools; using Microsoft.Extensions.Logging; namespace ClawdDotNet.Tools.FileRW; public sealed class FileRWTool : IAgentTool, IToolStateProbe { /// /// Per-Datei Locking: Verhindert Race Conditions wenn mehrere Agenten /// gleichzeitig auf dieselbe Datei zugreifen (read/write/append/delete/copy). /// Key = normalisierter absoluter Pfad (lowercase), Value = SemaphoreSlim(1,1). /// private static readonly ConcurrentDictionary FileLocks = new(); private static SemaphoreSlim GetFileLock(string fullPath) => FileLocks.GetOrAdd(NormalizePath(fullPath), _ => new SemaphoreSlim(1, 1)); private static string NormalizePath(string path) => Path.GetFullPath(path).ToLowerInvariant(); public string Name => "FileRW"; public string Description => "File management in workspaces:\n" + "- workspace='personal': Your PRIVATE folder — only you have access.\n" + "- workspace='shared': the instance's SHARED folder, if you have access to it.\n" + "- workspace='unit': your unit's workspace; other named workspaces you are a member of " + "are listed in the Organisation section of your instructions.\n" + "Actions: read, read_many, write, append, list, delete, copy, stock_add.\n" + "'read_many' reads multiple files in ONE call - either 'paths' (list) or 'path' " + "(directory) with 'pattern' (e.g. \"berlin_2026-09-20_*.txt\"). Use this instead of reading " + "many files individually: each individual call costs a step.\n" + "'list' by default shows the 50 most recently modified entries, newest first; " + "'pattern', 'sort', 'order' and 'limit' control this. It is better to query a specific file " + "using 'pattern' rather than searching through a long list.\n" + "For copy, files can be copied between workspaces.\n" + "'stock_add' adds a new data point to the stock knowledge base (shared:stocks/{ticker}/). " + "Protected paths (protectedPaths) only allow creating new files — no overwriting or deleting."; public JsonElement InputSchema { get; } = JsonDocument.Parse(""" { "type": "object", "properties": { "action": { "type": "string", "enum": ["read", "read_many", "write", "append", "list", "delete", "copy", "stock_add"], "description": "The action to perform. 'read_many' reads multiple files at once. 'stock_add' adds a structured data point to the stock knowledge base." }, "workspace": { "type": "string", "description": "The workspace in which to execute the action (source workspace for copy): 'personal', 'shared', 'unit' or the name of a workspace you are a member of. Default is 'personal'." }, "path": { "type": "string", "description": "Relative path to the file or directory" }, "content": { "type": "string", "description": "Content for write or append actions" }, "paths": { "type": "array", "items": { "type": "string" }, "description": "Only for read_many: files to read. Alternatively 'path' (directory) with 'pattern'." }, "pattern": { "type": "string", "description": "For read_many and list: file pattern within directory 'path', e.g. 'berlin_2026-09-20_*.txt'. Without pattern, the entire directory is used." }, "sort": { "type": "string", "enum": ["modified", "name"], "description": "Only for list: sorting. Default 'modified' (newest first)." }, "order": { "type": "string", "enum": ["desc", "asc"], "description": "Only for list: sort direction. Default descending for 'modified', ascending for 'name'." }, "limit": { "type": "integer", "description": "Only for list: maximum number of entries. Default 50, maximum 500. The response header always mentions total count." }, "destinationWorkspace": { "type": "string", "description": "Destination workspace for copy, named like 'workspace'. Default is the same workspace as 'workspace'." }, "destinationPath": { "type": "string", "description": "Relative destination path for copy. Required for copy." }, "ticker": { "type": "string", "description": "Stock ticker for stock_add (e.g. 'NVDA', 'TSLA'). Automatically converted to uppercase." }, "category": { "type": "string", "enum": ["news", "social", "analysis", "capitol", "price", "earnings", "filing", "sentiment", "other"], "description": "Category of data point for stock_add." }, "source": { "type": "string", "description": "Source of data point for stock_add (e.g. 'reuters', 'x_unusual_whales', 'youtube', 'reddit')." }, "title": { "type": "string", "description": "Short headline/summary of data point for stock_add." }, "data": { "type": "object", "description": "Structured data of data point for stock_add. Can contain arbitrary fields." } }, "required": ["action"] } """).RootElement.Clone(); public async Task ExecuteAsync( JsonElement input, AgentToolContext context, CancellationToken ct) { var action = input.GetProperty("action").GetString() ?? throw new ArgumentException("'action' is required"); // #718: "shared:betrieb/protokoll.md" names the workspace in the path — as in FTP and RocketChat. if (WorkspaceFromPath(ref input) is { } prefixProblem) return ToolResult.Fail(prefixProblem); var requested = input.TryGetProperty("workspace", out var w) && w.ValueKind == JsonValueKind.String ? w.GetString() : null; if (ResolveArea(requested, context) is not { } area) return ToolResult.Fail(UnknownWorkspace(requested!, context)); if (string.IsNullOrWhiteSpace(area.Root)) return ToolResult.Fail($"Configuration error: the folder of workspace '{area.Name}' is not defined."); // Sicherstellen, dass rootPath absolut ist var rootPath = Path.GetFullPath(area.Root); // Zugriffsprüfung if (!IsActionAllowed(action, area)) { return ToolResult.Fail($"Access denied: Action '{action}' is not allowed in workspace '{area.Name}' for this agent."); } // A workspace with a member list comes into being when it is first used. if (!area.IsPersonal && area.Name != WorkspaceLayout.Shared) Directory.CreateDirectory(rootPath); try { return action switch { "read" => await HandleReadAsync(input, rootPath, area, context, ct), "read_many" => await HandleReadManyAsync(input, rootPath, area, context, ct), "write" => await HandleWriteAsync(input, rootPath, area, context, ct), "append" => await HandleAppendAsync(input, rootPath, area, context, ct), "list" => await HandleListAsync(input, rootPath, area, context, ct), "delete" => await HandleDeleteAsync(input, rootPath, area, context, ct), "copy" => await HandleCopyAsync(input, rootPath, area, context, ct), "stock_add" => await HandleStockAddAsync(input, context, ct), _ => ToolResult.Fail($"Unknown action: {action}") }; } catch (UnauthorizedAccessException ex) { // A refused path is the agent's mistake, not the tool's — no stack trace in the error log. context.Logger.LogWarning("FileRW {Action} refused: {Reason}", action, ex.Message); return ToolResult.Fail(Denied(ex.Message)); } catch (Exception ex) { context.Logger.LogError(ex, "Error during FileRW action {Action}", action); return ToolResult.Fail($"Error: {ex.Message}"); } } /// /// #579: Rechte und Pfad pruefen, bevor ein Aufruf in die Freigabeliste kommt. Dieselben /// Pruefungen wie in , ohne etwas anzufassen. /// public string? Preflight(JsonElement input, AgentToolContext context) { if (input.ValueKind != JsonValueKind.Object || !input.TryGetProperty("action", out var a) || a.ValueKind != JsonValueKind.String) return null; var action = a.GetString() ?? ""; if (WorkspaceFromPath(ref input) is { } prefixProblem) return prefixProblem; var requested = input.TryGetProperty("workspace", out var w) && w.ValueKind == JsonValueKind.String ? w.GetString() : null; if (ResolveArea(requested, context) is not { } area) return UnknownWorkspace(requested!, context); if (!IsActionAllowed(action, area)) return $"Access denied: Action '{action}' is not allowed in workspace '{area.Name}' for this agent."; if (action != "delete" || string.IsNullOrWhiteSpace(area.Root)) return null; try { var rootPath = Path.GetFullPath(area.Root); var path = GetAndValidatePath(input, rootPath, area, false, context); if (!CanOverrideProtection(area) && IsPathProtected(path, rootPath, area)) return $"🛡️ Protected area: '{Path.GetRelativePath(rootPath, path)}' cannot be deleted."; } catch (Exception ex) when (ex is not OperationCanceledException) { // Dieselbe Pfadpruefung wirft bei der Ausfuehrung - dort wird daraus ein Fehler. return Denied(ex.Message); } return null; } private static string Denied(string message) => message.StartsWith("Access denied", StringComparison.Ordinal) ? message : $"Access denied: {message}"; /// /// #718: A path may name its workspace — shared:betrieb/protokoll.md, the form FTP and /// RocketChat take. Agents carry it over to FileRW, and every refusal cost them a step. The /// prefix becomes the workspace argument; it must agree with one given, and a list of /// paths names one workspace. Returns the refusal, or null. /// internal static string? WorkspaceFromPath(ref JsonElement input) { if (input.ValueKind != JsonValueKind.Object) return null; // The relative path, or null without a prefix; "\0" when it names a second workspace. string? named = null; string? Strip(string path) { if (path.IndexOf(':') <= 0) return null; var (workspace, relative) = NamedWorkspaces.Split(path); if (relative.Length == path.Length) return null; if (named is not null && !Same(named, workspace)) return "\0"; named = workspace; return relative; } var node = System.Text.Json.Nodes.JsonNode.Parse(input.GetRawText())!.AsObject(); var changed = false; if (node["path"] is System.Text.Json.Nodes.JsonValue pv && pv.TryGetValue(out var single) && Strip(single) is { } rel) { node["path"] = rel; changed = true; } if (node["paths"] is System.Text.Json.Nodes.JsonArray list) { for (var i = 0; i < list.Count; i++) { if (list[i] is not System.Text.Json.Nodes.JsonValue v || !v.TryGetValue(out var entry)) continue; var stripped = Strip(entry); if (stripped == "\0") return "'paths' names more than one workspace. Read from one workspace per call (parameter 'workspace')."; if (stripped is not null) { list[i] = stripped; changed = true; } } } if (!changed || named is null) return null; if (node["workspace"] is System.Text.Json.Nodes.JsonValue wv && wv.TryGetValue(out var given) && !string.IsNullOrWhiteSpace(given) && !Same(given, named)) return $"The path names the workspace '{named}', but 'workspace' says '{given}'. Give one of them."; node["workspace"] = named; input = JsonSerializer.SerializeToElement(node); return null; } private static bool Same(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); // ─── Workspaces (#684) ─── /// The folder in the shared workspace that holds the task files. private const string TasksFolder = "tasks"; /// A workspace as one call sees it: where it lies and what the agent may do there. private sealed record Area(string Name, string? Root, WorkspaceAccess Access, IReadOnlyList ProtectedPaths) { public bool IsPersonal => Name == WorkspaceLayout.Personal; } /// /// Resolves the name an agent gave. "personal" and "shared" work as they always did — /// "shared" still by the agent's own sharedAccessLevel. "unit" is the own unit's /// workspace; any other name must be a workspace the agent is a member of. Everything /// else is null: an unknown name and a workspace the agent may not see get the /// same answer, so the answer does not reveal that it exists. /// private static Area? ResolveArea(string? requested, AgentToolContext context) { var name = string.IsNullOrWhiteSpace(requested) ? WorkspaceLayout.Personal : requested.Trim(); if (name.Equals(WorkspaceLayout.Personal, StringComparison.OrdinalIgnoreCase)) return new Area(WorkspaceLayout.Personal, context.WorkspacePath, WorkspaceAccess.Manage, []); if (name.Equals(WorkspaceLayout.Shared, StringComparison.OrdinalIgnoreCase)) return new Area(WorkspaceLayout.Shared, context.SharedWorkspacePath, SharedAccess(context), GetProtectedPaths(context)); // #718: the same resolution as FTP and RocketChat. return NamedWorkspaces.Resolve(name, context.AgentId, context.Organisation, context.SharedWorkspacePath) is { } named ? new Area(named.Workspace.Id, named.Root, named.Access, NormaliseProtectedPaths(named.Workspace.ProtectedPaths)) : null; } /// The answer to a name that is not a workspace this agent may use — with the ones it may. private static string UnknownWorkspace(string requested, AgentToolContext context) => NamedWorkspaces.Unknown(requested, context.AgentId, context.Organisation, SharedAccess(context) > WorkspaceAccess.None); /// The one shared folder, by the level the agent carries for it — as before #684. private static WorkspaceAccess SharedAccess(AgentToolContext context) => (context.ToolConfig.TryGetValue("sharedAccessLevel", out var lv) ? lv?.ToString() : "Denied") switch { "Read" => WorkspaceAccess.Read, "ReadWrite" => WorkspaceAccess.Write, "Admin" => WorkspaceAccess.Manage, _ => WorkspaceAccess.None }; private static bool IsActionAllowed(string action, Area area) { if (area.IsPersonal) return true; // Alles erlaubt return action switch { "read" or "read_many" or "list" or "copy" => area.Access >= WorkspaceAccess.Read, "write" or "append" or "stock_add" => area.Access >= WorkspaceAccess.Write, "delete" => area.Access >= WorkspaceAccess.Manage, _ => false }; } /// Managing a workspace includes changing and deleting protected files — as the old Admin level did. private static bool CanOverrideProtection(Area area) => area.Access >= WorkspaceAccess.Manage; /// /// Prüft ob ein Pfad innerhalb eines geschützten Bereichs liegt. /// Geschützte Pfade erlauben nur: read, list, append, write (nur neue Dateien), stock_add. /// Kein Überschreiben existierender Dateien, kein Löschen. /// Wer den Bereich verwaltet, umgeht den Schutz. /// private static bool IsPathProtected(string fullPath, string rootPath, Area area) { if (area.ProtectedPaths.Count == 0) return false; var relativePath = Path.GetRelativePath(rootPath, fullPath) .Replace('\\', '/').TrimStart('/'); // Jeder Eintrag endet auf "/", deshalb greift StartsWith hier auf einer echten // Verzeichnisgrenze: "stocks/" trifft "stocks/x.json", aber nicht "stocks-alt/x.json". return area.ProtectedPaths.Any(pp => relativePath.StartsWith(pp, StringComparison.OrdinalIgnoreCase) || relativePath.Equals(pp.TrimEnd('/'), StringComparison.OrdinalIgnoreCase)); } /// /// #684: With units, the task files in the shared folder are closed to FileRW. Written /// directly, a task file would pass every check the task board makes — the board of the /// unit, the address book of the assignment. Tasks go through the Taskboard tool. Without /// units nothing changes, so running installations keep what they have. /// private static bool IsBlocked(string fullPath, Area area, AgentToolContext context) { if (area.Name != WorkspaceLayout.Shared || context.Organisation?.HasUnits != true || string.IsNullOrWhiteSpace(area.Root)) return false; var relative = Path.GetRelativePath(Path.GetFullPath(area.Root), fullPath).Replace('\\', '/'); return relative.Equals(TasksFolder, StringComparison.OrdinalIgnoreCase) || relative.StartsWith(TasksFolder + "/", StringComparison.OrdinalIgnoreCase); } // ─── History (#719) ─── /// The workspace as the history names it — the personal one with its owner, since every agent has one. private static string HistoryWorkspace(Area area, AgentToolContext context) => area.IsPersonal ? $"{WorkspaceLayout.Personal}:{context.AgentId}" : area.Name; /// /// Keeps the version before the change. Best effort: a history that cannot be written /// must not stop the work — it is logged, and the change happens without its entry. /// private static async Task BeforeChangeAsync(string path, Area area, AgentToolContext context, CancellationToken ct) { if (context.History is not { } history) return null; try { return await history.BeforeAsync(path, HistoryWorkspace(area, context), ct); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or Microsoft.Data.Sqlite.SqliteException) { context.Logger.LogWarning(ex, "File history: the version before could not be kept for {Path}", path); return null; } } private static async Task AfterChangeAsync(FileChange? change, string action, AgentToolContext context, CancellationToken ct) { if (change is null || context.History is not { } history) return; try { await history.RecordAsync(change, action, context.AgentId, context.RunId, ct); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or Microsoft.Data.Sqlite.SqliteException) { context.Logger.LogWarning(ex, "File history: the change could not be recorded for {Path}", change.FullPath); } } // ─── Lost updates (#720) ─── /// Only workspaces more than one agent can write to — never the personal one. private static bool Guarded(Area area, AgentToolContext context) => !area.IsPersonal && context.Files is not null; private static void NoteRead(string path, Area area, AgentToolContext context) { if (Guarded(area, context)) context.Files!.NoteRead(context.AgentId, path, FileLedger.HashOf(path)); } private static void NoteReplace(string path, Area area, AgentToolContext context) { if (Guarded(area, context)) context.Files!.NoteReplace(context.AgentId, context.RunId, path, FileLedger.HashOf(path)); } /// /// The refusal when replacing the file would lose a change this agent has not seen — /// kept for the analysis, audited as a conflict. null: go ahead. Called under the /// file's lock, so nothing can change between the check and the write. /// private static async Task RefuseLostUpdateAsync( string path, string displayPath, Area area, AgentToolContext context, CancellationToken ct) { if (!Guarded(area, context)) return null; if (context.Files!.CheckReplace(context.AgentId, context.RunId, path, area.Name, FileLedger.HashOf(path)) is not { } conflict) return null; context.Logger.LogInformation("Write refused, lost update: {Path} by {Agent} ({Kind})", displayPath, context.AgentId, conflict.Kind); await context.Files.RecordAsync(conflict, ct); return ToolResult.Conflict(conflict.Message(displayPath)); } private static List NormaliseProtectedPaths(IEnumerable paths) => paths .Select(p => p.Replace('\\', '/').Trim().Trim('/')) .Where(p => p.Length > 0) .Select(p => p + "/") .ToList(); private static List GetProtectedPaths(AgentToolContext context) { if (!context.ToolConfig.TryGetValue("protectedPaths", out var pp) || pp is null) return []; if (pp is JsonElement je && je.ValueKind == JsonValueKind.Array) return je.EnumerateArray() .Select(e => e.GetString()?.Replace('\\', '/').Trim().TrimEnd('/') + "/") .Where(s => !string.IsNullOrEmpty(s)) .ToList()!; if (pp is string s && !string.IsNullOrWhiteSpace(s)) return s.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(p => p.Replace('\\', '/').TrimEnd('/') + "/") .ToList(); return []; } /// /// Nachschau nach einem Absturz (#555). Sieht nach, was von einem unterbrochenen /// Aufruf übrig ist — verändert nichts. /// /// Wie weit die Auskunft trägt, hängt an der Aktion: /// /// write — seit #553 atomar. Entweder die alte oder die neue Fassung, /// nie eine halbe. Ein Vergleich mit dem beabsichtigten Inhalt sagt deshalb /// zuverlässig, ob der Aufruf durchkam. /// append — nicht atomar und nicht atomar zu machen. Hier kann wirklich /// ein Riss entstehen, und hier lohnt die Nachschau: Steht der beabsichtigte Text /// vollständig am Ende, kam der Aufruf durch; steht ein Anfang davon da, riss es /// mittendrin. /// alles andere — . Lieber schweigen als /// raten. /// /// public async Task ProbeAsync(JsonElement arguments, AgentToolContext context, CancellationToken ct) { try { var action = arguments.TryGetProperty("action", out var a) ? a.GetString() : null; if (action is not ("write" or "append")) return ProbeFinding.Unknown($"Nothing to probe for action '{action ?? "?"}'."); if (WorkspaceFromPath(ref arguments) is { } prefixProblem) return ProbeFinding.Unknown(prefixProblem); var requested = arguments.TryGetProperty("workspace", out var w) && w.ValueKind == JsonValueKind.String ? w.GetString() : null; if (ResolveArea(requested, context) is not { Root: { Length: > 0 } root } area) return ProbeFinding.Unknown("Workspace path is unknown."); var path = GetAndValidatePath(arguments, Path.GetFullPath(root), area, true, context); var erwartet = arguments.TryGetProperty("content", out var c) ? c.GetString() ?? "" : ""; if (!File.Exists(path)) { return new ProbeFinding(ProbeOutcome.NotStarted, "File does not exist — nothing was written."); } var vorhanden = await File.ReadAllTextAsync(path, ct); if (action == "write") { return vorhanden == erwartet ? new ProbeFinding(ProbeOutcome.Complete, $"Content matches intended content exactly ({erwartet.Length} characters).") : new ProbeFinding(ProbeOutcome.NotStarted, "File still has old content. Writes are atomic — partial versions cannot exist."); } // append if (vorhanden.EndsWith(erwartet, StringComparison.Ordinal)) { return new ProbeFinding(ProbeOutcome.Complete, $"Intended text is completely at the end ({erwartet.Length} characters)."); } // Wie viel vom Erwarteten steht am Ende? Das ist die Rissstelle. for (var laenge = Math.Min(erwartet.Length - 1, vorhanden.Length); laenge > 0; laenge--) { if (vorhanden.EndsWith(erwartet[..laenge], StringComparison.Ordinal)) { return new ProbeFinding(ProbeOutcome.Partial, $"Appended {laenge} of {erwartet.Length} characters — call was interrupted."); } } return new ProbeFinding(ProbeOutcome.NotStarted, "None of the intended text is at the end of the file."); } catch (Exception ex) { // Nie werfen: Eine gescheiterte Nachschau ist keine Aussage über die Welt, // sondern eine über die Nachschau. return ProbeFinding.Unknown($"Probe not possible: {ex.Message}"); } } private string GetAndValidatePath(JsonElement input, string rootPath, Area area, bool checkExtension, AgentToolContext context) { var relativePath = input.TryGetProperty("path", out var p) ? p.GetString() : ""; // Prüft Ausbruch aus dem Workspace, absolute Pfade und Alternate Data Streams. var fullPath = WorkspacePath.Resolve(rootPath, relativePath, area.Name); if (IsBlocked(fullPath, area, context)) throw new UnauthorizedAccessException( "Tasks are kept on the task board — use the Taskboard tool (task_list, task_find, task_create, task_update), not their files."); // Extension Check if (checkExtension && !Directory.Exists(fullPath)) { var extension = Path.GetExtension(fullPath).ToLowerInvariant(); var configKey = area.IsPersonal ? "personalAllowedExtensions" : "sharedAllowedExtensions"; var allowedExtensions = context.ToolConfig.TryGetValue(configKey, out var ae) && ae is JsonElement je ? je.EnumerateArray().Select(x => x.GetString()?.ToLowerInvariant()).ToList() : new List { ".txt", ".json", ".md", ".html", ".js", ".css" }; // Default if (!allowedExtensions.Contains(extension)) { throw new UnauthorizedAccessException($"File extension '{extension}' is not allowed in workspace '{area.Name}'."); } } return fullPath; } /// /// #662: Gibt es den Pfad in einem anderen Workspace, sagt die Fehlermeldung das. /// /// Anlass aus dem Testbetrieb: Ein guenstiges Modell vergisst regelmaessig /// workspace: "shared" und bekommt nur "nicht gefunden" zurueck. Es rät dann /// weiter, und jeder Versuch kostet einen Schritt. Der Hinweis nennt nur Workspaces, /// die der Agent fuer diese Aktion auch benutzen darf - sonst waere er eine Einladung /// zu etwas, das ohnehin abgewiesen wird, und verriete obendrein, dass es sie gibt. /// /// Seit #684 gibt es mehr als zwei: gesucht wird in allen, die der Agent kennt. /// private string AndererWorkspaceHinweis( string action, Area area, string? relativePath, AgentToolContext context, bool verzeichnis, string? muster = null) { // The root of a workspace exists everywhere — pointing there would send the agent in circles. if (string.IsNullOrWhiteSpace(relativePath) && string.IsNullOrWhiteSpace(muster)) return ""; var candidates = new List { WorkspaceLayout.Personal, WorkspaceLayout.Shared }; if (context.Organisation is { } org) candidates.AddRange(org.WorkspacesFor(context.AgentId).Select(g => g.Workspace.Id)); foreach (var name in candidates.Where(n => !string.Equals(n, area.Name, StringComparison.OrdinalIgnoreCase))) { if (ResolveArea(name, context) is not { Root: { Length: > 0 } root } other || !IsActionAllowed(action, other)) continue; try { var pfad = WorkspacePath.Resolve(Path.GetFullPath(root), relativePath, other.Name); if (IsBlocked(pfad, other, context)) continue; var dort = verzeichnis ? Directory.Exists(pfad) && (string.IsNullOrWhiteSpace(muster) || Directory.EnumerateFiles(pfad, muster).Any()) : File.Exists(pfad); if (dort) return string.IsNullOrWhiteSpace(muster) ? $" The path exists in the {other.Name} workspace — call with workspace: \"{other.Name}\"." : $" Matching files exist in the {other.Name} workspace — call with workspace: \"{other.Name}\"."; } catch { // Ein Pfad, der dort gar nicht auflösbar ist, hilft niemandem. } } return ""; } private static string? RelativerPfad(JsonElement input) => input.ValueKind == JsonValueKind.Object && input.TryGetProperty("path", out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null; private async Task HandleReadAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct) { var path = GetAndValidatePath(input, rootPath, area, true, context); if (!File.Exists(path)) { return ToolResult.Fail( $"File not found in {area.Name} workspace: {Path.GetRelativePath(rootPath, path)}." + AndererWorkspaceHinweis("read", area, RelativerPfad(input), context, verzeichnis: false)); } var fileLock = GetFileLock(path); await fileLock.WaitAsync(ct); try { var content = await File.ReadAllTextAsync(path, ct); NoteRead(path, area, context); var relativePath = Path.GetRelativePath(rootPath, path); return ToolResult.Ok($"[{area.Name}:/{relativePath}]\n{content}"); } finally { fileLock.Release(); } } /// Hoechstzahl Dateien je read_many. public const int MaxDateienJeAufruf = 50; /// Hoechstzahl Zeichen je read_many - danach werden nur noch Namen genannt. public const int MaxZeichenJeAufruf = 200_000; /// /// #663: Mehrere Dateien in einem Aufruf lesen. /// /// Anlass: Der Tagesabschluss im Testbetrieb muss 24 Stundendateien zu einem /// Tagesarchiv zusammenfassen. Einzeln gelesen sind das 24 Schritte, und der Lauf /// stirbt an der Schrittgrenze, bevor er fertig ist. Die Grenze soll Endlosschleifen /// abfangen, nicht ehrliche Arbeit - also liest das Werkzeug jetzt auf einmal. /// /// Entweder paths (ausdrueckliche Liste) oder path als Verzeichnis /// mit optionalem pattern. Gedeckelt auf Dateien /// und Zeichen; was nicht mehr hineinpasst, wird am /// Ende namentlich genannt, damit der Agent den Rest gezielt nachholen kann. /// private async Task HandleReadManyAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct) { var (dateien, fehler) = SammleDateien(input, rootPath, area, context); if (fehler is not null) return ToolResult.Fail(fehler); if (dateien.Count == 0) { return ToolResult.Fail( $"No matching file found in {area.Name} workspace." + AndererWorkspaceHinweis("read_many", area, RelativerPfad(input), context, verzeichnis: true, muster: input.TryGetProperty("pattern", out var m) && m.ValueKind == JsonValueKind.String ? m.GetString() : null)); } var uebersprungen = new List(); var fehlend = new List(); var text = new StringBuilder(); var gelesen = 0; foreach (var pfad in dateien.Take(MaxDateienJeAufruf)) { ct.ThrowIfCancellationRequested(); var relativ = Path.GetRelativePath(rootPath, pfad).Replace('\\', '/'); if (!File.Exists(pfad)) { fehlend.Add(relativ); continue; } if (text.Length >= MaxZeichenJeAufruf) { uebersprungen.Add(relativ); continue; } var sperre = GetFileLock(pfad); await sperre.WaitAsync(ct); string inhalt; try { inhalt = await File.ReadAllTextAsync(pfad, ct); NoteRead(pfad, area, context); } finally { sperre.Release(); } text.Append($"\n--- {area.Name}:/{relativ} ---\n").Append(inhalt.TrimEnd()).Append('\n'); gelesen++; } uebersprungen.AddRange(dateien.Skip(MaxDateienJeAufruf).Select(d => Path.GetRelativePath(rootPath, d).Replace('\\', '/'))); var kopf = new StringBuilder($"[{area.Name} Workspace] {gelesen} of {dateien.Count} file(s) read."); if (fehlend.Count > 0) kopf.Append($"\nNot found: {string.Join(", ", fehlend.Take(10))}{(fehlend.Count > 10 ? " …" : "")}"); if (uebersprungen.Count > 0) kopf.Append($"\nSkipped reading (limit {MaxDateienJeAufruf} files / {MaxZeichenJeAufruf} chars), " + $"fetch with an additional call: {string.Join(", ", uebersprungen.Take(10))}{(uebersprungen.Count > 10 ? " …" : "")}"); return ToolResult.Ok(kopf.Append('\n').Append(text).ToString()); } /// /// Die zu lesenden Dateien: entweder aus paths oder aus Verzeichnis und Muster. /// Jeder Pfad geht durch dieselbe Pruefung wie ein einzelnes read. /// private (List Dateien, string? Fehler) SammleDateien( JsonElement input, string rootPath, Area area, AgentToolContext context) { try { if (input.TryGetProperty("paths", out var liste) && liste.ValueKind == JsonValueKind.Array) { var dateien = new List(); foreach (var eintrag in liste.EnumerateArray()) { if (eintrag.ValueKind != JsonValueKind.String) continue; var relativ = eintrag.GetString(); if (string.IsNullOrWhiteSpace(relativ)) continue; dateien.Add(PruefeLesepfad(relativ, rootPath, area, context)); } return (dateien, dateien.Count == 0 ? "'paths' contains no valid path." : null); } var verzeichnis = WorkspacePath.Resolve(rootPath, RelativerPfad(input), area.Name); if (!Directory.Exists(verzeichnis)) return ([], null); // Die Meldung samt Workspace-Hinweis macht der Aufrufer. var muster = input.TryGetProperty("pattern", out var m) && m.ValueKind == JsonValueKind.String ? m.GetString() : null; if (MusterFehler(muster) is { } musterFehler) return ([], musterFehler); var gefunden = Directory.GetFiles(verzeichnis, string.IsNullOrWhiteSpace(muster) ? "*" : muster) .OrderBy(f => f, StringComparer.Ordinal) .ToList(); // Dieselbe Endungspruefung wie beim einzelnen Lesen - was dort verboten ist, // kommt auch hier nicht durch, nur ohne den Lauf abzubrechen. return (gefunden.Where(f => IstLesbar(f, area, context)).ToList(), null); } catch (UnauthorizedAccessException ex) { return ([], Denied(ex.Message)); } } private string PruefeLesepfad(string relativePath, string rootPath, Area area, AgentToolContext context) { var json = JsonSerializer.SerializeToElement(new { action = "read", path = relativePath }); return GetAndValidatePath(json, rootPath, area, true, context); } private bool IstLesbar(string vollerPfad, Area area, AgentToolContext context) { try { var json = JsonSerializer.SerializeToElement(new { action = "read", path = Path.GetFileName(vollerPfad) }); GetAndValidatePath(json, Path.GetDirectoryName(vollerPfad)!, area, true, context); return true; } catch (UnauthorizedAccessException) { return false; } } private async Task HandleWriteAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct) { var path = GetAndValidatePath(input, rootPath, area, true, context); var content = input.TryGetProperty("content", out var c) ? c.GetString() ?? "" : ""; var fileLock = GetFileLock(path); await fileLock.WaitAsync(ct); try { // Schutz: In geschützten Bereichen darf nur geschrieben werden wenn die Datei NICHT existiert if (!CanOverrideProtection(area) && File.Exists(path) && IsPathProtected(path, rootPath, area)) { var relPath = Path.GetRelativePath(rootPath, path); return ToolResult.Fail( $"🛡️ Protected area: File '{relPath}' already exists and cannot be overwritten. " + $"Use 'append' to add data, or create a new file with a different name. " + $"Tip: Use 'stock_add' for structured data points."); } // #720: not over a change this agent has not seen. if (await RefuseLostUpdateAsync(path, $"{area.Name}:/{Path.GetRelativePath(rootPath, path).Replace('\\', '/')}", area, context, ct) is { } conflict) return conflict; var dir = Path.GetDirectoryName(path); if (dir != null && !Directory.Exists(dir)) Directory.CreateDirectory(dir); var change = await BeforeChangeAsync(path, area, context, ct); // Atomar schreiben (#553): erst in eine Nebendatei, dann ersetzen. Ein Absturz // mitten im Vorgang hinterlässt damit entweder die alte oder die neue Fassung, // nie eine halbe. Zuvor stand hier File.WriteAllTextAsync — die Anwendung // schützte also ihre eigenen Konfigurationsdateien, die Arbeitsergebnisse der // Agenten aber nicht. await AtomicFile.WriteAllTextAsync(path, content, new UTF8Encoding(false), ct); NoteReplace(path, area, context); await AfterChangeAsync(change, FileAction.Replace, context, ct); return ToolResult.Ok($"[{area.Name} Workspace] File successfully written: {area.Name}:/{Path.GetRelativePath(rootPath, path)}"); } finally { fileLock.Release(); } } private async Task HandleAppendAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct) { var path = GetAndValidatePath(input, rootPath, area, true, context); var content = input.TryGetProperty("content", out var c) ? c.GetString() ?? "" : ""; var fileLock = GetFileLock(path); await fileLock.WaitAsync(ct); try { // Bewusst NICHT atomar (#553): Anhängen ließe sich nur atomar machen, indem // die ganze Datei neu geschrieben wird — bei einer großen Protokolldatei wäre // das der falsche Handel. Hier bleibt ein Riss möglich; genau dafür gibt es // die Zustandsprüfung in #555. // #720: appending loses nothing and is not checked — but noted, so whoever read // the file before does not replace it without the appended part. var before = Guarded(area, context) ? FileLedger.HashOf(path) : null; var change = await BeforeChangeAsync(path, area, context, ct); // #575: Neu entstehende Dateien gehoeren nur dem Besitzer (0600). await using (var stream = ClawdDotNet.Core.Storage.OwnerOnlyFile.OpenAppend(path)) await using (var writer = new StreamWriter(stream, new UTF8Encoding(false))) await writer.WriteAsync(content.AsMemory(), ct); if (Guarded(area, context)) context.Files!.NoteAppend(context.AgentId, context.RunId, path, before, FileLedger.HashOf(path)); await AfterChangeAsync(change, FileAction.Append, context, ct); return ToolResult.Ok($"[{area.Name} Workspace] Content successfully appended to: {area.Name}:/{Path.GetRelativePath(rootPath, path)}"); } finally { fileLock.Release(); } } /// Wie viele Eintraege ein list ohne ausdrueckliches limit zeigt. public const int StandardEintraege = 50; /// Obergrenze fuer limit - darueber wird die Antwort zur Heuhaufensuche. public const int MaxEintraege = 500; /// /// #673: Verzeichnis auflisten - sortiert, gedeckelt, kompakt. /// /// Anlass: Die Betriebskontrolle im Testbetrieb meldete Stundendateien als fehlend, /// die es gab. Das Verzeichnis lieferte 105 Eintraege in der Reihenfolge des Dateisystems, /// vierzeilig als eingeruecktes JSON. Darin zwei bestimmte Namen zu finden ist Sucharbeit, /// und ein guenstiges Modell erledigt sie falsch. /// /// Also: neueste zuerst (umstellbar ueber sort und order), pattern /// zum Eingrenzen, eine Zeile je Eintrag - und der Kopf sagt, wie viele Eintraege es /// insgesamt gibt. Eine gedeckelte Liste, die ihre Deckelung verschweigt, waere die /// schlimmere Falle. /// private async Task HandleListAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct) { var path = GetAndValidatePath(input, rootPath, area, false, context); if (!Directory.Exists(path)) { return ToolResult.Fail( $"Directory not found in {area.Name} workspace: {Path.GetRelativePath(rootPath, path)}." + AndererWorkspaceHinweis("list", area, RelativerPfad(input), context, verzeichnis: true)); } var muster = GetOptionalString(input, "pattern"); if (MusterFehler(muster) is { } musterFehler) return ToolResult.Fail(musterFehler); List pfade; try { pfade = Directory.GetFileSystemEntries(path, string.IsNullOrWhiteSpace(muster) ? "*" : muster) .Where(e => !IsBlocked(e, area, context)) .ToList(); } catch (ArgumentException ex) { return ToolResult.Fail($"Invalid pattern '{muster}': {ex.Message}"); } var eintraege = pfade .Select(e => new Verzeichniseintrag( Path.GetFileName(e), Directory.Exists(e), Directory.Exists(e) ? 0 : new FileInfo(e).Length, File.GetLastWriteTime(e))) .ToList(); var nachName = string.Equals(GetOptionalString(input, "sort"), "name", StringComparison.OrdinalIgnoreCase); var absteigend = GetOptionalString(input, "order") is { } o ? string.Equals(o, "desc", StringComparison.OrdinalIgnoreCase) : !nachName; // newest first, names ascending IEnumerable sortiert = nachName ? (absteigend ? eintraege.OrderByDescending(e => e.Name, StringComparer.OrdinalIgnoreCase) : eintraege.OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase)) : (absteigend ? eintraege.OrderByDescending(e => e.Geaendert) : eintraege.OrderBy(e => e.Geaendert)); var grenze = Math.Clamp(GetInt(input, "limit") ?? StandardEintraege, 1, MaxEintraege); var gezeigt = sortiert.Take(grenze).ToList(); var relativeDirPath = Path.GetRelativePath(rootPath, path); var ordnung = nachName ? (absteigend ? "name descending" : "name ascending") : (absteigend ? "newest first" : "oldest first"); var text = new StringBuilder(); text.Append($"[{area.Name} Workspace] Directory: {area.Name}:/{(relativeDirPath == "." ? "" : relativeDirPath)}"); text.Append($" - {gezeigt.Count} of {eintraege.Count} entries, {ordnung}."); if (!string.IsNullOrWhiteSpace(muster)) text.Append($" Pattern: {muster}."); if (eintraege.Count > gezeigt.Count) text.Append($"\n{eintraege.Count - gezeigt.Count} more not shown - narrow down with 'pattern' or set 'limit' (max {MaxEintraege})."); text.Append("\nModified Size Name\n"); foreach (var e in gezeigt) { var groesse = e.IstVerzeichnis ? "" : e.Groesse.ToString(); text.Append($"{e.Geaendert:yyyy-MM-dd HH:mm} {groesse,9} {e.Name}{(e.IstVerzeichnis ? "/" : "")}\n"); } return await Task.FromResult(ToolResult.Ok(text.ToString().TrimEnd())); } private readonly record struct Verzeichniseintrag(string Name, bool IstVerzeichnis, long Groesse, DateTime Geaendert); /// /// Ein Muster ist ein Dateiname mit Platzhaltern, kein Pfad. Directory.GetFiles /// wertet Trennzeichen im Muster aus — pattern: "../*" listet damit das /// Elternverzeichnis, an der Pfadpruefung vorbei. Deshalb hier und nicht erst dort. /// private static string? MusterFehler(string? muster) { if (string.IsNullOrWhiteSpace(muster)) return null; if (muster.Contains('/') || muster.Contains('\\') || muster.Contains("..")) return $"Invalid pattern '{muster}': a pattern is a file name with wildcards " + "(e.g. 'berlin_2026-09-21_*.txt'), not a path. The directory belongs in 'path'."; return null; } private static string? GetOptionalString(JsonElement input, string name) => input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; private static int? GetInt(JsonElement input, string name) => input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i) ? i : null; private async Task HandleDeleteAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct) { var path = GetAndValidatePath(input, rootPath, area, false, context); // Schutz: In geschützten Bereichen ist Löschen komplett verboten (außer Admin) if (!CanOverrideProtection(area) && IsPathProtected(path, rootPath, area)) { var relPath = Path.GetRelativePath(rootPath, path); return ToolResult.Fail( $"🛡️ Protected area: '{relPath}' is in a protected directory and cannot be deleted. " + $"Data in protected areas is append-only — it can only be added, not removed."); } var fileLock = GetFileLock(path); await fileLock.WaitAsync(ct); try { if (File.Exists(path)) { var change = await BeforeChangeAsync(path, area, context, ct); File.Delete(path); await AfterChangeAsync(change, FileAction.Delete, context, ct); return ToolResult.Ok($"[{area.Name} Workspace] File deleted: {area.Name}:/{Path.GetRelativePath(rootPath, path)}"); } else if (Directory.Exists(path)) { // #719: every file in it is a deletion of its own in the history. var changes = new List(); foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) changes.Add(await BeforeChangeAsync(file, area, context, ct)); Directory.Delete(path, true); foreach (var change in changes) await AfterChangeAsync(change, FileAction.Delete, context, ct); return ToolResult.Ok($"[{area.Name} Workspace] Directory deleted: {area.Name}:/{Path.GetRelativePath(rootPath, path)}"); } return ToolResult.Fail($"File or directory not found in {area.Name} workspace."); } finally { fileLock.Release(); } } private async Task HandleCopyAsync(JsonElement input, string sourceRootPath, Area sourceArea, AgentToolContext context, CancellationToken ct) { var sourcePath = GetAndValidatePath(input, sourceRootPath, sourceArea, false, context); var requestedDestination = input.TryGetProperty("destinationWorkspace", out var dw) && dw.ValueKind == JsonValueKind.String ? dw.GetString() : null; var destRelPath = input.TryGetProperty("destinationPath", out var dp) ? dp.GetString() : null; if (string.IsNullOrWhiteSpace(destRelPath)) return ToolResult.Fail("'destinationPath' is required for copy action."); var destArea = requestedDestination is null ? sourceArea : ResolveArea(requestedDestination, context); if (destArea is null) return ToolResult.Fail(UnknownWorkspace(requestedDestination!, context)); if (string.IsNullOrWhiteSpace(destArea.Root)) return ToolResult.Fail($"Configuration error: the folder of workspace '{destArea.Name}' is not defined."); var destRootPath = Path.GetFullPath(destArea.Root); if (!IsActionAllowed("write", destArea)) return ToolResult.Fail($"Access denied: Writing to workspace '{destArea.Name}' is not allowed for this agent."); var destInput = JsonDocument.Parse(JsonSerializer.Serialize(new { path = destRelPath })).RootElement; var destPath = GetAndValidatePath(destInput, destRootPath, destArea, false, context); if (File.Exists(sourcePath)) { // Schutz: Kein Überschreiben in geschützten Bereichen per Copy if (!CanOverrideProtection(destArea) && File.Exists(destPath) && IsPathProtected(destPath, destRootPath, destArea)) { var relPath = Path.GetRelativePath(destRootPath, destPath); return ToolResult.Fail( $"🛡️ Protected area: Destination file '{relPath}' already exists and cannot be overwritten."); } // Deadlock-sicheres Locking: Immer in alphabetischer Reihenfolge locken var srcNorm = NormalizePath(sourcePath); var dstNorm = NormalizePath(destPath); var first = string.Compare(srcNorm, dstNorm, StringComparison.Ordinal) <= 0 ? GetFileLock(sourcePath) : GetFileLock(destPath); var second = string.Compare(srcNorm, dstNorm, StringComparison.Ordinal) <= 0 ? GetFileLock(destPath) : GetFileLock(sourcePath); await first.WaitAsync(ct); try { await second.WaitAsync(ct); try { // #720: copying over a file replaces it — the same check as a write. if (await RefuseLostUpdateAsync(destPath, $"{destArea.Name}:/{Path.GetRelativePath(destRootPath, destPath).Replace('\\', '/')}", destArea, context, ct) is { } conflict) return conflict; var destDir = Path.GetDirectoryName(destPath); if (destDir != null && !Directory.Exists(destDir)) Directory.CreateDirectory(destDir); var change = await BeforeChangeAsync(destPath, destArea, context, ct); await Task.Run(() => File.Copy(sourcePath, destPath, overwrite: !IsPathProtected(destPath, destRootPath, destArea)), ct); NoteReplace(destPath, destArea, context); await AfterChangeAsync(change, FileAction.Copy, context, ct); var srcLabel = $"{sourceArea.Name}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}"; var dstLabel = $"{destArea.Name}:/{Path.GetRelativePath(destRootPath, destPath)}"; return ToolResult.Ok($"File copied: {srcLabel} → {dstLabel}"); } finally { second.Release(); } } finally { first.Release(); } } else if (Directory.Exists(sourcePath)) { // #720/#719: each file the copy would replace is checked and recorded like a single copy. var targets = Directory.EnumerateFiles(sourcePath, "*", SearchOption.AllDirectories) .Select(f => Path.Combine(destPath, Path.GetRelativePath(sourcePath, f))) .ToList(); foreach (var target in targets) { var shown = $"{destArea.Name}:/{Path.GetRelativePath(destRootPath, target).Replace('\\', '/')}"; if (await RefuseLostUpdateAsync(target, shown, destArea, context, ct) is { } conflict) return conflict; } var changes = new List(); foreach (var target in targets) changes.Add(await BeforeChangeAsync(target, destArea, context, ct)); await Task.Run(() => CopyDirectory(sourcePath, destPath), ct); foreach (var (target, change) in targets.Zip(changes)) { NoteReplace(target, destArea, context); await AfterChangeAsync(change, FileAction.Copy, context, ct); } var srcLabel = $"{sourceArea.Name}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}"; var dstLabel = $"{destArea.Name}:/{Path.GetRelativePath(destRootPath, destPath)}"; return ToolResult.Ok($"Directory copied: {srcLabel} → {dstLabel}"); } return ToolResult.Fail("Source file or directory not found."); } // ═══════════════════════════════════════════════════ // STOCK DATABASE (Append-Only Wissensdatenbank) // ═══════════════════════════════════════════════════ private static readonly JsonSerializerOptions StockJsonOpts = new() { WriteIndented = true, PropertyNameCaseInsensitive = true, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; private async Task HandleStockAddAsync(JsonElement input, AgentToolContext context, CancellationToken ct) { // Pflichtfelder var ticker = (input.TryGetProperty("ticker", out var t) ? t.GetString() : null)?.Trim().ToUpperInvariant(); if (string.IsNullOrWhiteSpace(ticker)) return ToolResult.Fail("'ticker' is required for stock_add (e.g. 'NVDA', 'TSLA')."); var category = (input.TryGetProperty("category", out var cat) ? cat.GetString() : null) ?? "other"; var source = (input.TryGetProperty("source", out var src) ? src.GetString() : null) ?? "unknown"; var title = (input.TryGetProperty("title", out var ttl) ? ttl.GetString() : null) ?? ""; var content = input.TryGetProperty("content", out var cnt) ? cnt.GetString() : null; var data = input.TryGetProperty("data", out var d) ? d : (JsonElement?)null; if (string.IsNullOrWhiteSpace(title) && string.IsNullOrWhiteSpace(content) && data is null) return ToolResult.Fail("At least 'title', 'content', or 'data' must be specified."); // SharedWorkspace ist Pflicht für stocks var sharedRoot = context.SharedWorkspacePath; if (string.IsNullOrWhiteSpace(sharedRoot)) return ToolResult.Fail("SharedWorkspace is not configured."); sharedRoot = Path.GetFullPath(sharedRoot); // Zugriffsprüfung if (ResolveArea(WorkspaceLayout.Shared, context) is not { } shared || !IsActionAllowed("stock_add", shared)) return ToolResult.Fail("Access denied: Writing to SharedWorkspace is not allowed."); // Sanitize Ticker für Verzeichnisname var safeTicker = SanitizeFileName(ticker); var stockDir = Path.Combine(sharedRoot, "stocks", safeTicker); Directory.CreateDirectory(stockDir); // Timestamp-basierten Dateinamen generieren var now = DateTime.UtcNow; var timestamp = now.ToString("yyyyMMdd_HHmmss"); var safeSource = SanitizeFileName(source); var fileName = $"{timestamp}_{category}_{safeSource}.json"; var filePath = Path.Combine(stockDir, fileName); // Kollisionsvermeidung var counter = 1; while (File.Exists(filePath)) { fileName = $"{timestamp}_{category}_{safeSource}_{counter}.json"; filePath = Path.Combine(stockDir, fileName); counter++; } // Datenpunkt erstellen var entry = new Dictionary { ["id"] = $"{safeTicker}_{timestamp}_{category}_{safeSource}", ["ticker"] = ticker, ["category"] = category, ["source"] = source, ["title"] = title, ["timestamp"] = now.ToString("o"), ["agent"] = context.AgentId }; if (!string.IsNullOrWhiteSpace(content)) entry["content"] = content; if (data.HasValue) entry["data"] = data.Value; // Datenpunkt speichern var json = JsonSerializer.Serialize(entry, StockJsonOpts); var stockArea = new Area(WorkspaceLayout.Shared, sharedRoot, WorkspaceAccess.Write, []); var dataChange = await BeforeChangeAsync(filePath, stockArea, context, ct); await ClawdDotNet.Core.Storage.AtomicFile.WriteAllTextAsync(filePath, json, new UTF8Encoding(false), ct); await AfterChangeAsync(dataChange, FileAction.Create, context, ct); // _index.json aktualisieren (thread-safe via per-file SemaphoreSlim, append-only) var indexPath = Path.Combine(stockDir, "_index.json"); var indexEntry = new Dictionary { ["file"] = fileName, ["category"] = category, ["source"] = source, ["title"] = title, ["timestamp"] = now.ToString("o"), ["agent"] = context.AgentId }; int indexCount; var indexLock = GetFileLock(indexPath); await indexLock.WaitAsync(ct); try { List> index; if (File.Exists(indexPath)) { try { var existing = await File.ReadAllTextAsync(indexPath, ct); index = JsonSerializer.Deserialize>>(existing, StockJsonOpts) ?? []; } catch (JsonException) { // Korrupter Index: Backup + Neustart var backup = indexPath + $".bak_{now:yyyyMMdd_HHmmss}"; try { File.Copy(indexPath, backup, overwrite: true); } catch { /* best effort */ } index = []; } } else { index = []; } index.Add(indexEntry); var indexChange = await BeforeChangeAsync(indexPath, stockArea, context, ct); // Atomar: Der Index ist die einzige Übersicht über die Datenpunkte — // eine halb geschriebene Datei wäre nicht wiederherstellbar. await ClawdDotNet.Core.Storage.AtomicFile.WriteAllTextAsync( indexPath, JsonSerializer.Serialize(index, StockJsonOpts), ct: ct); await AfterChangeAsync(indexChange, FileAction.Append, context, ct); // Den Stand hier festhalten, solange die Sperre noch gilt (B9). Vorher // wurde die Datei nach dem Freigeben ein zweites Mal gelesen und geparst: // ein paralleler stock_add konnte dazwischenschreiben, und die gemeldete // Zahl gehörte dann zu einem anderen Aufruf als die gemeldete Datei. // Innerhalb der Sperre ist der Zähler ohnehin bekannt. indexCount = index.Count; } finally { indexLock.Release(); } var relPath = $"shared:/stocks/{safeTicker}/{fileName}"; return ToolResult.Ok( $"✅ Data point added: {relPath}\n" + $"Ticker: {ticker} | Category: {category} | Source: {source}\n" + $"Title: {title}\n" + $"Index: {indexCount} entries for {ticker}"); } private static string SanitizeFileName(string name) { var sanitized = name.Trim(); foreach (var c in Path.GetInvalidFileNameChars()) sanitized = sanitized.Replace(c, '_'); return sanitized.Replace(' ', '_'); } private static void CopyDirectory(string sourceDir, string destinationDir) { Directory.CreateDirectory(destinationDir); foreach (var file in Directory.GetFiles(sourceDir)) File.Copy(file, Path.Combine(destinationDir, Path.GetFileName(file)), overwrite: true); foreach (var dir in Directory.GetDirectories(sourceDir)) CopyDirectory(dir, Path.Combine(destinationDir, Path.GetFileName(dir))); } }