diff --git a/CHANGELOG.md b/CHANGELOG.md
index e4365b1..72373cd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,21 @@
All notable changes to DevBrain are tracked in this file. Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+Two new document-editing tools add a safe preview/apply workflow for exact text replacements. This keeps DevBrain's whole-document storage model intact while giving AI callers a deterministic way to edit without stale overwrites.
+
+### Added
+- **`PreviewEditDocument(key, oldText, newText, expectedOccurrences?, caseSensitive?, project?)`** — previews a literal text replacement without writing. Returns match count, before/after snippets, and the current `contentHash` for the caller to feed into apply.
+- **`ApplyEditDocument(key, oldText, newText, expectedContentHash, expectedOccurrences?, caseSensitive?, project?)`** — applies the same literal replacement only if the stored document still matches the preview hash. Refuses ambiguous or stale edits.
+- **`DocumentEditService`** — shared edit-planning/execution layer that finds literal matches, builds preview snippets, and computes the final whole-document replacement payload.
+- **`ConditionalWriteResult` + `IDocumentStore.ReplaceIfHashMatchesAsync(...)`** — conditional write path used by apply to avoid overwriting a document that changed after preview.
+- **`ContentHashing` helper** — centralizes content-hash computation and normalization so compare/edit flows share the same hash semantics.
+
+### Changed
+- `README.md` now documents the new edit tools and the recommended preview/apply workflow.
+- `docs/seed/ref-devbrain-usage.md` now teaches AI callers how to edit existing documents safely using `PreviewEditDocument` followed by `ApplyEditDocument`.
+
## [1.7.0] — 2026-04-12
Two new read-only tools that let callers check whether a document has changed without pulling the full content into context. Every write now stamps a SHA-256 `contentHash` and `contentLength` on the document, enabling cheap staleness checks and import-or-skip decisions.
diff --git a/NuGet.Config b/NuGet.Config
new file mode 100644
index 0000000..765346e
--- /dev/null
+++ b/NuGet.Config
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index 150d996..7d1cb0a 100644
--- a/README.md
+++ b/README.md
@@ -169,10 +169,47 @@ All tools accept an optional `project` parameter (defaults to `"default"`) to is
| `GetDocument` | `key` (required), `project` | Retrieve a document by key |
| `GetDocumentMetadata` | `key` (required), `project` | Retrieve document metadata (tags, timestamps, contentHash, contentLength) without the content body |
| `CompareDocument` | `key` (required), `content` or `contentHash` (one required), `project` | Check whether candidate content matches a stored document by SHA-256 hash |
+| `PreviewEditDocument` | `key` (required), `oldText` (required), `newText` (required), `expectedOccurrences`, `caseSensitive`, `project` | Preview a literal text replacement without writing; returns match count, before/after preview, and the current content hash |
+| `ApplyEditDocument` | `key` (required), `oldText` (required), `newText` (required), `expectedContentHash` (required), `expectedOccurrences`, `caseSensitive`, `project` | Apply a literal text replacement only if the document still matches the preview hash |
| `ListDocuments` | `prefix`, `project` | List document keys, optionally filtered by prefix |
| `SearchDocuments` | `query` (required), `project` | Substring search across keys and content |
| `DeleteDocument` | `key` (required), `project` | Delete a document by key. Idempotent on missing keys. |
+### Editing Documents Safely
+
+DevBrain still stores documents as whole values, but it now supports a safe two-step edit flow for exact text changes:
+
+1. Call `PreviewEditDocument` with the literal `oldText` and `newText`
+2. Inspect the returned `matchCount`, preview snippets, and `currentContentHash`
+3. Call `ApplyEditDocument` with the same edit inputs and `expectedContentHash`
+
+Why two steps:
+
+- **Ambiguity guard.** Preview refuses edits when the number of matches differs from `expectedOccurrences` (defaults to `1`).
+- **Concurrency guard.** Apply fails if the stored content hash changed after preview, preventing stale overwrites.
+- **Agent-friendly ergonomics.** Exact snippet replacement is more reliable than offsets or regex for most AI callers.
+
+Example:
+
+```text
+PreviewEditDocument(
+ key="state:current",
+ project="devbrain",
+ oldText="Status: draft",
+ newText="Status: in progress"
+)
+```
+
+```text
+ApplyEditDocument(
+ key="state:current",
+ project="devbrain",
+ oldText="Status: draft",
+ newText="Status: in progress",
+ expectedContentHash=""
+)
+```
+
### When to use Append vs Chunked
Both tools exist to work around the LLM-client per-turn output budget, but they solve different problems:
diff --git a/docs/seed/ref-devbrain-usage.md b/docs/seed/ref-devbrain-usage.md
index ec9e8ea..4f8ae24 100644
--- a/docs/seed/ref-devbrain-usage.md
+++ b/docs/seed/ref-devbrain-usage.md
@@ -4,7 +4,7 @@
DevBrain organizes documents by project. If a query returns no results, you are likely searching the wrong project scope.
-**Always specify the project parameter explicitly.** The default project ("default") is empty for most use cases.
+**Always specify the project parameter explicitly.** Use `default` for shared reference docs or quick sandboxing, and use named projects like `devbrain` for real project state.
## Known Projects
- `devbrain` — DevBrain's own documentation, architecture, sprint docs, backlog, known issues
@@ -68,6 +68,46 @@ Returns `{ found, match, storedContentHash, candidateHash, ... }`. The server ha
This pattern avoids pulling the full stored document into context just to decide whether to write.
+## Editing Existing Documents Safely — Preview and Apply
+
+For exact text edits, use the guarded two-step flow instead of doing a blind full overwrite.
+
+### Preview an exact edit first
+```
+PreviewEditDocument(
+ key: "ref:devbrain-usage",
+ oldText: "old phrase",
+ newText: "new phrase",
+ project: "default",
+ expectedOccurrences: 1
+)
+```
+Preview returns whether the edit is possible, how many matches were found, whether the request is ambiguous, and the current `contentHash` to use when applying.
+
+### Apply only against the previewed version
+```
+ApplyEditDocument(
+ key: "ref:devbrain-usage",
+ oldText: "old phrase",
+ newText: "new phrase",
+ project: "default",
+ expectedOccurrences: 1,
+ expectedContentHash: ""
+)
+```
+If the document changed after preview, apply refuses the write and tells you to preview again.
+
+### Recommended edit workflow
+1. Call `PreviewEditDocument`
+2. Confirm `WouldReplace: true` and the `MatchCount` is what you expected
+3. Pass the returned `CurrentContentHash` into `ApplyEditDocument`
+4. If apply reports that the document changed since preview, re-run preview and try again
+
+### Useful guardrails
+- Use `expectedOccurrences` to refuse zero-match or multi-match edits unless they are intentional
+- Use `caseSensitive: true` when casing matters and you want to avoid accidental matches
+- Use `UpsertDocument` when you are replacing the whole document on purpose; use preview/apply when you want a narrow, literal patch
+
## Key Conventions
Keys use **colon** as the separator. Slash-separated keys (`sprint/foo`) still work for backward compatibility, but colons are the canonical, recommended convention — they signal "DevBrain key" at a glance and avoid being confused with file paths.
diff --git a/src/DevBrain.Functions/Models/ConditionalWriteResult.cs b/src/DevBrain.Functions/Models/ConditionalWriteResult.cs
new file mode 100644
index 0000000..b75522c
--- /dev/null
+++ b/src/DevBrain.Functions/Models/ConditionalWriteResult.cs
@@ -0,0 +1,7 @@
+namespace DevBrain.Functions.Models;
+
+public sealed record ConditionalWriteResult(
+ bool Applied,
+ string? CurrentContentHash,
+ BrainDocument? Document,
+ string Message);
diff --git a/src/DevBrain.Functions/Models/EditApplyResult.cs b/src/DevBrain.Functions/Models/EditApplyResult.cs
new file mode 100644
index 0000000..ccfde63
--- /dev/null
+++ b/src/DevBrain.Functions/Models/EditApplyResult.cs
@@ -0,0 +1,26 @@
+namespace DevBrain.Functions.Models;
+
+public sealed class EditApplyResult
+{
+ public string Key { get; set; } = string.Empty;
+
+ public string Project { get; set; } = "default";
+
+ public bool Applied { get; set; }
+
+ public int MatchCount { get; set; }
+
+ public int ReplacedCount { get; set; }
+
+ public string? PreviousContentHash { get; set; }
+
+ public string? NewContentHash { get; set; }
+
+ public int? NewContentLength { get; set; }
+
+ public DateTimeOffset? UpdatedAt { get; set; }
+
+ public string? UpdatedBy { get; set; }
+
+ public string Message { get; set; } = string.Empty;
+}
diff --git a/src/DevBrain.Functions/Models/EditPreviewResult.cs b/src/DevBrain.Functions/Models/EditPreviewResult.cs
new file mode 100644
index 0000000..05058d3
--- /dev/null
+++ b/src/DevBrain.Functions/Models/EditPreviewResult.cs
@@ -0,0 +1,30 @@
+namespace DevBrain.Functions.Models;
+
+public sealed class EditPreviewResult
+{
+ public string Key { get; set; } = string.Empty;
+
+ public string Project { get; set; } = "default";
+
+ public bool Found { get; set; }
+
+ public int MatchCount { get; set; }
+
+ public int ExpectedOccurrences { get; set; }
+
+ public bool Ambiguous { get; set; }
+
+ public bool WouldReplace { get; set; }
+
+ public string? CurrentContentHash { get; set; }
+
+ public int? CurrentContentLength { get; set; }
+
+ public int ReplacementDelta { get; set; }
+
+ public string? PreviewBefore { get; set; }
+
+ public string? PreviewAfter { get; set; }
+
+ public string Message { get; set; } = string.Empty;
+}
diff --git a/src/DevBrain.Functions/Program.cs b/src/DevBrain.Functions/Program.cs
index ab26b08..623a15b 100644
--- a/src/DevBrain.Functions/Program.cs
+++ b/src/DevBrain.Functions/Program.cs
@@ -72,6 +72,7 @@
});
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
// ---------------- OAuth DCR facade (v1.6) ----------------
diff --git a/src/DevBrain.Functions/Services/ContentHashing.cs b/src/DevBrain.Functions/Services/ContentHashing.cs
new file mode 100644
index 0000000..ded3b0f
--- /dev/null
+++ b/src/DevBrain.Functions/Services/ContentHashing.cs
@@ -0,0 +1,22 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace DevBrain.Functions.Services;
+
+internal static class ContentHashing
+{
+ public static string ComputeSha256(string content)
+ {
+ var normalized = NormalizeForHash(content);
+ var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
+ return Convert.ToHexStringLower(bytes);
+ }
+
+ ///
+ /// Normalizes content before hashing so that trivial formatting differences
+ /// (line-ending style, trailing whitespace) don't produce different hashes
+ /// for semantically identical documents. The stored content is never modified.
+ ///
+ public static string NormalizeForHash(string content) =>
+ content.ReplaceLineEndings("\n").TrimEnd();
+}
diff --git a/src/DevBrain.Functions/Services/CosmosDocumentStore.cs b/src/DevBrain.Functions/Services/CosmosDocumentStore.cs
index ccfd79a..73f6187 100644
--- a/src/DevBrain.Functions/Services/CosmosDocumentStore.cs
+++ b/src/DevBrain.Functions/Services/CosmosDocumentStore.cs
@@ -1,6 +1,4 @@
using System.Net;
-using System.Security.Cryptography;
-using System.Text;
using DevBrain.Functions.Models;
using Microsoft.Azure.Cosmos;
using Microsoft.Extensions.Configuration;
@@ -35,7 +33,7 @@ public CosmosDocumentStore(CosmosClient cosmosClient, IConfiguration configurati
public async Task UpsertAsync(BrainDocument document)
{
document.Id = EncodeId(document.Key);
- document.ContentHash = ComputeSha256(document.Content);
+ document.ContentHash = ContentHashing.ComputeSha256(document.Content);
document.ContentLength = document.Content.Length;
var response = await _container.UpsertItemAsync(
document,
@@ -45,20 +43,88 @@ public async Task UpsertAsync(BrainDocument document)
private static string EncodeId(string key) => key.Replace('/', ':');
- private static string ComputeSha256(string content)
+ public async Task ReplaceIfHashMatchesAsync(BrainDocument document, string expectedContentHash)
{
- var normalized = NormalizeForHash(content);
- var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
- return Convert.ToHexStringLower(bytes);
- }
+ var queryDefinition = new QueryDefinition(
+ "SELECT * FROM c WHERE c.key = @key AND c.project = @project OFFSET 0 LIMIT 1")
+ .WithParameter("@key", document.Key)
+ .WithParameter("@project", document.Project);
- ///
- /// Normalizes content before hashing so that trivial formatting differences
- /// (line-ending style, trailing whitespace) don't produce different hashes
- /// for semantically identical documents. The stored content is never modified.
- ///
- private static string NormalizeForHash(string content) =>
- content.ReplaceLineEndings("\n").TrimEnd();
+ using var iterator = _container.GetItemQueryIterator(queryDefinition);
+ if (!iterator.HasMoreResults)
+ {
+ return new ConditionalWriteResult(
+ Applied: false,
+ CurrentContentHash: null,
+ Document: null,
+ Message: $"Document not found: '{document.Key}'");
+ }
+
+ var response = await iterator.ReadNextAsync();
+ var existing = response.FirstOrDefault();
+ if (existing is null)
+ {
+ return new ConditionalWriteResult(
+ Applied: false,
+ CurrentContentHash: null,
+ Document: null,
+ Message: $"Document not found: '{document.Key}'");
+ }
+
+ ItemResponse currentResponse;
+ try
+ {
+ currentResponse = await _container.ReadItemAsync(
+ existing.Id,
+ new PartitionKey(existing.Key));
+ }
+ catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
+ {
+ return new ConditionalWriteResult(
+ Applied: false,
+ CurrentContentHash: null,
+ Document: null,
+ Message: $"Document not found: '{document.Key}'");
+ }
+
+ existing = currentResponse.Resource;
+ var currentHash = existing.ContentHash ?? ContentHashing.ComputeSha256(existing.Content);
+ if (!string.Equals(currentHash, expectedContentHash, StringComparison.OrdinalIgnoreCase))
+ {
+ return new ConditionalWriteResult(
+ Applied: false,
+ CurrentContentHash: currentHash,
+ Document: existing,
+ Message: "Document changed since preview. Re-run PreviewEditDocument and try again.");
+ }
+
+ document.Id = existing.Id;
+ document.ContentHash = ContentHashing.ComputeSha256(document.Content);
+ document.ContentLength = document.Content.Length;
+
+ try
+ {
+ var replaceResponse = await _container.ReplaceItemAsync(
+ document,
+ existing.Id,
+ new PartitionKey(existing.Key),
+ new ItemRequestOptions { IfMatchEtag = currentResponse.ETag });
+
+ return new ConditionalWriteResult(
+ Applied: true,
+ CurrentContentHash: currentHash,
+ Document: replaceResponse.Resource,
+ Message: "Write applied.");
+ }
+ catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed)
+ {
+ return new ConditionalWriteResult(
+ Applied: false,
+ CurrentContentHash: currentHash,
+ Document: null,
+ Message: "Document changed during apply. Re-run PreviewEditDocument and try again.");
+ }
+ }
public async Task GetAsync(string key, string project)
{
@@ -316,7 +382,7 @@ public async Task AppendAsync(
Tags = UnionTags(existing.Tags, tags),
UpdatedAt = DateTimeOffset.UtcNow,
UpdatedBy = updatedBy,
- ContentHash = ComputeSha256(mergedContent),
+ ContentHash = ContentHashing.ComputeSha256(mergedContent),
ContentLength = mergedContent.Length
};
diff --git a/src/DevBrain.Functions/Services/DocumentEditService.cs b/src/DevBrain.Functions/Services/DocumentEditService.cs
new file mode 100644
index 0000000..2f35321
--- /dev/null
+++ b/src/DevBrain.Functions/Services/DocumentEditService.cs
@@ -0,0 +1,253 @@
+using System.Text;
+using DevBrain.Functions.Models;
+
+namespace DevBrain.Functions.Services;
+
+public sealed class DocumentEditService : IDocumentEditService
+{
+ private const int PreviewContextChars = 120;
+
+ private readonly IDocumentStore _store;
+
+ public DocumentEditService(IDocumentStore store)
+ {
+ _store = store;
+ }
+
+ public async Task PreviewAsync(
+ string key,
+ string project,
+ string oldText,
+ string newText,
+ int expectedOccurrences,
+ bool caseSensitive)
+ {
+ var document = await _store.GetAsync(key, project);
+ if (document is null)
+ {
+ return new EditPreviewResult
+ {
+ Key = key,
+ Project = project,
+ Found = false,
+ ExpectedOccurrences = expectedOccurrences,
+ WouldReplace = false,
+ Message = $"Document not found: '{key}'"
+ };
+ }
+
+ var currentHash = document.ContentHash ?? ContentHashing.ComputeSha256(document.Content);
+ var matchIndexes = FindMatchIndexes(document.Content, oldText, caseSensitive);
+ var matchCount = matchIndexes.Count;
+
+ if (matchCount == 0)
+ {
+ return new EditPreviewResult
+ {
+ Key = key,
+ Project = project,
+ Found = true,
+ MatchCount = 0,
+ ExpectedOccurrences = expectedOccurrences,
+ CurrentContentHash = currentHash,
+ CurrentContentLength = document.Content.Length,
+ WouldReplace = false,
+ Message = $"No matches found for the provided text in '{key}'."
+ };
+ }
+
+ if (matchCount != expectedOccurrences)
+ {
+ return new EditPreviewResult
+ {
+ Key = key,
+ Project = project,
+ Found = true,
+ MatchCount = matchCount,
+ ExpectedOccurrences = expectedOccurrences,
+ Ambiguous = matchCount > 1,
+ CurrentContentHash = currentHash,
+ CurrentContentLength = document.Content.Length,
+ WouldReplace = false,
+ Message = $"Expected {expectedOccurrences} match(es), found {matchCount}. Refusing ambiguous edit."
+ };
+ }
+
+ var replacedContent = ReplaceMatches(document.Content, oldText, newText, matchIndexes);
+
+ return new EditPreviewResult
+ {
+ Key = key,
+ Project = project,
+ Found = true,
+ MatchCount = matchCount,
+ ExpectedOccurrences = expectedOccurrences,
+ Ambiguous = false,
+ WouldReplace = true,
+ CurrentContentHash = currentHash,
+ CurrentContentLength = document.Content.Length,
+ ReplacementDelta = replacedContent.Length - document.Content.Length,
+ PreviewBefore = BuildPreview(document.Content, matchIndexes[0], oldText.Length),
+ PreviewAfter = BuildPreview(replacedContent, matchIndexes[0], newText.Length),
+ Message = "Edit preview ready."
+ };
+ }
+
+ public async Task ApplyAsync(
+ string key,
+ string project,
+ string oldText,
+ string newText,
+ int expectedOccurrences,
+ bool caseSensitive,
+ string expectedContentHash,
+ string updatedBy)
+ {
+ var document = await _store.GetAsync(key, project);
+ if (document is null)
+ {
+ return new EditApplyResult
+ {
+ Key = key,
+ Project = project,
+ Applied = false,
+ Message = $"Document not found: '{key}'"
+ };
+ }
+
+ var currentHash = document.ContentHash ?? ContentHashing.ComputeSha256(document.Content);
+ if (!string.Equals(currentHash, expectedContentHash, StringComparison.OrdinalIgnoreCase))
+ {
+ return new EditApplyResult
+ {
+ Key = key,
+ Project = project,
+ Applied = false,
+ PreviousContentHash = currentHash,
+ Message = "Document changed since preview. Re-run PreviewEditDocument and try again."
+ };
+ }
+
+ var matchIndexes = FindMatchIndexes(document.Content, oldText, caseSensitive);
+ var matchCount = matchIndexes.Count;
+ if (matchCount != expectedOccurrences)
+ {
+ return new EditApplyResult
+ {
+ Key = key,
+ Project = project,
+ Applied = false,
+ MatchCount = matchCount,
+ PreviousContentHash = currentHash,
+ Message = matchCount == 0
+ ? $"No matches found for the provided text in '{key}'."
+ : $"Expected {expectedOccurrences} match(es), found {matchCount}. Refusing ambiguous edit."
+ };
+ }
+
+ var replacedContent = ReplaceMatches(document.Content, oldText, newText, matchIndexes);
+ var updated = new BrainDocument
+ {
+ Id = document.Id,
+ Key = document.Key,
+ Project = document.Project,
+ Content = replacedContent,
+ Tags = document.Tags,
+ UpdatedAt = DateTimeOffset.UtcNow,
+ UpdatedBy = updatedBy,
+ Ttl = document.Ttl
+ };
+
+ var writeResult = await _store.ReplaceIfHashMatchesAsync(updated, expectedContentHash);
+ if (!writeResult.Applied)
+ {
+ return new EditApplyResult
+ {
+ Key = key,
+ Project = project,
+ Applied = false,
+ MatchCount = matchCount,
+ ReplacedCount = 0,
+ PreviousContentHash = writeResult.CurrentContentHash ?? currentHash,
+ Message = writeResult.Message
+ };
+ }
+
+ var saved = writeResult.Document!;
+ return new EditApplyResult
+ {
+ Key = saved.Key,
+ Project = saved.Project,
+ Applied = true,
+ MatchCount = matchCount,
+ ReplacedCount = matchCount,
+ PreviousContentHash = currentHash,
+ NewContentHash = saved.ContentHash,
+ NewContentLength = saved.ContentLength ?? saved.Content.Length,
+ UpdatedAt = saved.UpdatedAt,
+ UpdatedBy = saved.UpdatedBy,
+ Message = "Edit applied."
+ };
+ }
+
+ private static List FindMatchIndexes(string content, string oldText, bool caseSensitive)
+ {
+ var comparison = caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
+ var indexes = new List();
+ var startIndex = 0;
+
+ while (startIndex <= content.Length - oldText.Length)
+ {
+ var matchIndex = content.IndexOf(oldText, startIndex, comparison);
+ if (matchIndex < 0)
+ {
+ break;
+ }
+
+ indexes.Add(matchIndex);
+ startIndex = matchIndex + oldText.Length;
+ }
+
+ return indexes;
+ }
+
+ private static string ReplaceMatches(string content, string oldText, string newText, IReadOnlyList matchIndexes)
+ {
+ if (matchIndexes.Count == 0)
+ {
+ return content;
+ }
+
+ var builder = new StringBuilder(content.Length + (newText.Length - oldText.Length) * matchIndexes.Count);
+ var cursor = 0;
+
+ foreach (var matchIndex in matchIndexes)
+ {
+ builder.Append(content, cursor, matchIndex - cursor);
+ builder.Append(newText);
+ cursor = matchIndex + oldText.Length;
+ }
+
+ builder.Append(content, cursor, content.Length - cursor);
+ return builder.ToString();
+ }
+
+ private static string BuildPreview(string content, int matchIndex, int matchLength)
+ {
+ var previewStart = Math.Max(0, matchIndex - PreviewContextChars);
+ var previewEnd = Math.Min(content.Length, matchIndex + matchLength + PreviewContextChars);
+ var preview = content[previewStart..previewEnd];
+
+ if (previewStart > 0)
+ {
+ preview = "..." + preview;
+ }
+
+ if (previewEnd < content.Length)
+ {
+ preview += "...";
+ }
+
+ return preview;
+ }
+}
diff --git a/src/DevBrain.Functions/Services/IDocumentEditService.cs b/src/DevBrain.Functions/Services/IDocumentEditService.cs
new file mode 100644
index 0000000..3621736
--- /dev/null
+++ b/src/DevBrain.Functions/Services/IDocumentEditService.cs
@@ -0,0 +1,24 @@
+using DevBrain.Functions.Models;
+
+namespace DevBrain.Functions.Services;
+
+public interface IDocumentEditService
+{
+ Task PreviewAsync(
+ string key,
+ string project,
+ string oldText,
+ string newText,
+ int expectedOccurrences,
+ bool caseSensitive);
+
+ Task ApplyAsync(
+ string key,
+ string project,
+ string oldText,
+ string newText,
+ int expectedOccurrences,
+ bool caseSensitive,
+ string expectedContentHash,
+ string updatedBy);
+}
diff --git a/src/DevBrain.Functions/Services/IDocumentStore.cs b/src/DevBrain.Functions/Services/IDocumentStore.cs
index d769852..7f95d7d 100644
--- a/src/DevBrain.Functions/Services/IDocumentStore.cs
+++ b/src/DevBrain.Functions/Services/IDocumentStore.cs
@@ -5,6 +5,7 @@ namespace DevBrain.Functions.Services;
public interface IDocumentStore
{
Task UpsertAsync(BrainDocument document);
+ Task ReplaceIfHashMatchesAsync(BrainDocument document, string expectedContentHash);
Task GetAsync(string key, string project);
Task> ListAsync(string project, string? prefix = null);
Task> SearchAsync(string query, string project);
diff --git a/src/DevBrain.Functions/Tools/DocumentTools.cs b/src/DevBrain.Functions/Tools/DocumentTools.cs
index ac1b03b..3d61c54 100644
--- a/src/DevBrain.Functions/Tools/DocumentTools.cs
+++ b/src/DevBrain.Functions/Tools/DocumentTools.cs
@@ -1,5 +1,3 @@
-using System.Security.Cryptography;
-using System.Text;
using System.Text.Json;
using DevBrain.Functions.Models;
using DevBrain.Functions.Services;
@@ -11,10 +9,12 @@ namespace DevBrain.Functions.Tools;
public sealed class DocumentTools
{
private readonly IDocumentStore _store;
+ private readonly IDocumentEditService _editService;
- public DocumentTools(IDocumentStore store)
+ public DocumentTools(IDocumentStore store, IDocumentEditService editService)
{
_store = store;
+ _editService = editService;
}
[Function(nameof(UpsertDocument))]
@@ -130,7 +130,7 @@ public async Task CompareDocument(
return "Provide either 'content' or 'contentHash', not both.";
}
- var candidateHash = contentHash ?? ComputeSha256(content!);
+ var candidateHash = contentHash ?? ContentHashing.ComputeSha256(content!);
var document = await _store.GetMetadataAsync(key, project ?? "default");
if (document is null)
@@ -159,6 +159,101 @@ public async Task CompareDocument(
});
}
+ [Function(nameof(PreviewEditDocument))]
+ public async Task PreviewEditDocument(
+ [McpToolTrigger("PreviewEditDocument", "Preview an exact text edit without writing. Matches literal text only. Returns match count, preview snippets, and the current content hash to pass into ApplyEditDocument.")]
+ ToolInvocationContext context,
+ [McpToolProperty("key", "Document key to edit.", isRequired: true)]
+ string key,
+ [McpToolProperty("oldText", "Exact literal text to find in the document.", isRequired: true)]
+ string oldText,
+ [McpToolProperty("newText", "Replacement text to substitute for oldText. May be empty to delete the match.", isRequired: true)]
+ string newText,
+ [McpToolProperty("expectedOccurrences", "Expected number of literal matches. Defaults to 1; preview refuses ambiguous edits when the actual count differs.")]
+ int? expectedOccurrences,
+ [McpToolProperty("caseSensitive", "When true, matching is case-sensitive. Defaults to false.")]
+ bool? caseSensitive,
+ [McpToolProperty("project", "Project scope (default: \"default\").")]
+ string? project)
+ {
+ if (string.IsNullOrEmpty(oldText))
+ {
+ return JsonSerializer.Serialize(new EditPreviewResult
+ {
+ Key = key,
+ Project = project ?? "default",
+ Found = false,
+ WouldReplace = false,
+ Message = "'oldText' must be a non-empty string."
+ });
+ }
+
+ var result = await _editService.PreviewAsync(
+ key,
+ project ?? "default",
+ oldText,
+ newText,
+ expectedOccurrences ?? 1,
+ caseSensitive ?? false);
+
+ return JsonSerializer.Serialize(result);
+ }
+
+ [Function(nameof(ApplyEditDocument))]
+ public async Task ApplyEditDocument(
+ [McpToolTrigger("ApplyEditDocument", "Apply an exact text edit after preview. Fails if the document changed since preview, if the match count differs, or if the edit is ambiguous. Matches literal text only.")]
+ ToolInvocationContext context,
+ [McpToolProperty("key", "Document key to edit.", isRequired: true)]
+ string key,
+ [McpToolProperty("oldText", "Exact literal text to find in the document.", isRequired: true)]
+ string oldText,
+ [McpToolProperty("newText", "Replacement text to substitute for oldText. May be empty to delete the match.", isRequired: true)]
+ string newText,
+ [McpToolProperty("expectedContentHash", "Content hash returned by PreviewEditDocument. Apply fails if the stored document no longer matches this hash.", isRequired: true)]
+ string expectedContentHash,
+ [McpToolProperty("expectedOccurrences", "Expected number of literal matches. Defaults to 1; apply refuses ambiguous edits when the actual count differs.")]
+ int? expectedOccurrences,
+ [McpToolProperty("caseSensitive", "When true, matching is case-sensitive. Defaults to false.")]
+ bool? caseSensitive,
+ [McpToolProperty("project", "Project scope (default: \"default\").")]
+ string? project,
+ FunctionContext functionContext)
+ {
+ if (string.IsNullOrEmpty(oldText))
+ {
+ return JsonSerializer.Serialize(new EditApplyResult
+ {
+ Key = key,
+ Project = project ?? "default",
+ Applied = false,
+ Message = "'oldText' must be a non-empty string."
+ });
+ }
+
+ if (string.IsNullOrWhiteSpace(expectedContentHash))
+ {
+ return JsonSerializer.Serialize(new EditApplyResult
+ {
+ Key = key,
+ Project = project ?? "default",
+ Applied = false,
+ Message = "'expectedContentHash' is required."
+ });
+ }
+
+ var result = await _editService.ApplyAsync(
+ key,
+ project ?? "default",
+ oldText,
+ newText,
+ expectedOccurrences ?? 1,
+ caseSensitive ?? false,
+ expectedContentHash,
+ GetCallerIdentity(functionContext));
+
+ return JsonSerializer.Serialize(result);
+ }
+
[Function(nameof(ListDocuments))]
public async Task ListDocuments(
[McpToolTrigger("ListDocuments", "List stored document keys, optionally filtered by prefix. If the project has no matching documents and a similarly-named project exists, a single suggestion entry (key \"_suggestion\") is returned instead.")]
@@ -407,13 +502,6 @@ public async Task SearchDocuments(
return $"Keys must use ':' as separator. Got '{key}' — did you mean '{suggested}'?";
}
- private static string ComputeSha256(string content)
- {
- var normalized = content.ReplaceLineEndings("\n").TrimEnd();
- var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
- return Convert.ToHexStringLower(bytes);
- }
-
private static string GetCallerIdentity(FunctionContext functionContext)
{
var features = functionContext.Features;
diff --git a/tests/DevBrain.Functions.Tests/Services/DocumentEditServiceTests.cs b/tests/DevBrain.Functions.Tests/Services/DocumentEditServiceTests.cs
new file mode 100644
index 0000000..fbc8b8f
--- /dev/null
+++ b/tests/DevBrain.Functions.Tests/Services/DocumentEditServiceTests.cs
@@ -0,0 +1,280 @@
+using DevBrain.Functions.Models;
+using DevBrain.Functions.Services;
+
+namespace DevBrain.Functions.Tests.Services;
+
+public sealed class DocumentEditServiceTests
+{
+ [Fact]
+ public async Task PreviewAsync_SingleExactMatch_ReturnsPreviewAndHash()
+ {
+ var store = new FakeDocumentStore(new BrainDocument
+ {
+ Id = "state:current",
+ Key = "state:current",
+ Project = "devbrain",
+ Content = "Status: draft\nOwner: Derek",
+ Tags = ["state"],
+ UpdatedAt = DateTimeOffset.UtcNow,
+ UpdatedBy = "seed"
+ });
+ var service = new DocumentEditService(store);
+
+ var result = await service.PreviewAsync(
+ "state:current",
+ "devbrain",
+ "Status: draft",
+ "Status: in progress",
+ expectedOccurrences: 1,
+ caseSensitive: true);
+
+ Assert.True(result.Found);
+ Assert.True(result.WouldReplace);
+ Assert.False(result.Ambiguous);
+ Assert.Equal(1, result.MatchCount);
+ Assert.Equal(ContentHashing.ComputeSha256("Status: draft\nOwner: Derek"), result.CurrentContentHash);
+ Assert.Contains("Status: draft", result.PreviewBefore);
+ Assert.Contains("Status: in progress", result.PreviewAfter);
+ }
+
+ [Fact]
+ public async Task PreviewAsync_MultipleMatchesWithSingleExpectation_RefusesAmbiguousEdit()
+ {
+ var store = new FakeDocumentStore(new BrainDocument
+ {
+ Id = "state:current",
+ Key = "state:current",
+ Project = "devbrain",
+ Content = "draft\ndraft\nfinal",
+ UpdatedAt = DateTimeOffset.UtcNow,
+ UpdatedBy = "seed"
+ });
+ var service = new DocumentEditService(store);
+
+ var result = await service.PreviewAsync(
+ "state:current",
+ "devbrain",
+ "draft",
+ "published",
+ expectedOccurrences: 1,
+ caseSensitive: true);
+
+ Assert.True(result.Found);
+ Assert.False(result.WouldReplace);
+ Assert.True(result.Ambiguous);
+ Assert.Equal(2, result.MatchCount);
+ }
+
+ [Fact]
+ public async Task ApplyAsync_WithMatchingHash_ReplacesContentAndPreservesTags()
+ {
+ var originalContent = "Status: draft\nOwner: Derek";
+ var store = new FakeDocumentStore(new BrainDocument
+ {
+ Id = "state:current",
+ Key = "state:current",
+ Project = "devbrain",
+ Content = originalContent,
+ Tags = ["state", "current"],
+ UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
+ UpdatedBy = "seed"
+ });
+ var service = new DocumentEditService(store);
+
+ var result = await service.ApplyAsync(
+ "state:current",
+ "devbrain",
+ "Status: draft",
+ "Status: in progress",
+ expectedOccurrences: 1,
+ caseSensitive: true,
+ expectedContentHash: ContentHashing.ComputeSha256(originalContent),
+ updatedBy: "agent@example.com");
+
+ Assert.True(result.Applied);
+ Assert.Equal(1, result.ReplacedCount);
+ Assert.Equal(ContentHashing.ComputeSha256(originalContent), result.PreviousContentHash);
+ Assert.Equal(ContentHashing.ComputeSha256("Status: in progress\nOwner: Derek"), result.NewContentHash);
+
+ var saved = await store.GetAsync("state:current", "devbrain");
+ Assert.NotNull(saved);
+ Assert.Equal("Status: in progress\nOwner: Derek", saved.Content);
+ Assert.Equal(["state", "current"], saved.Tags);
+ Assert.Equal("agent@example.com", saved.UpdatedBy);
+ }
+
+ [Fact]
+ public async Task ApplyAsync_WithStaleHash_RefusesWrite()
+ {
+ var store = new FakeDocumentStore(new BrainDocument
+ {
+ Id = "state:current",
+ Key = "state:current",
+ Project = "devbrain",
+ Content = "Status: draft",
+ UpdatedAt = DateTimeOffset.UtcNow,
+ UpdatedBy = "seed"
+ });
+ var service = new DocumentEditService(store);
+
+ var result = await service.ApplyAsync(
+ "state:current",
+ "devbrain",
+ "Status: draft",
+ "Status: in progress",
+ expectedOccurrences: 1,
+ caseSensitive: true,
+ expectedContentHash: ContentHashing.ComputeSha256("Status: stale"),
+ updatedBy: "agent@example.com");
+
+ Assert.False(result.Applied);
+ Assert.Equal(ContentHashing.ComputeSha256("Status: draft"), result.PreviousContentHash);
+
+ var saved = await store.GetAsync("state:current", "devbrain");
+ Assert.NotNull(saved);
+ Assert.Equal("Status: draft", saved.Content);
+ }
+
+ [Fact]
+ public async Task ApplyAsync_ReplacementContainingOldText_ReplacesOnlyExpectedMatches()
+ {
+ var store = new FakeDocumentStore(new BrainDocument
+ {
+ Id = "state:current",
+ Key = "state:current",
+ Project = "devbrain",
+ Content = "draft once",
+ UpdatedAt = DateTimeOffset.UtcNow,
+ UpdatedBy = "seed"
+ });
+ var service = new DocumentEditService(store);
+
+ var result = await service.ApplyAsync(
+ "state:current",
+ "devbrain",
+ "draft",
+ "draft v2",
+ expectedOccurrences: 1,
+ caseSensitive: true,
+ expectedContentHash: ContentHashing.ComputeSha256("draft once"),
+ updatedBy: "agent@example.com");
+
+ Assert.True(result.Applied);
+
+ var saved = await store.GetAsync("state:current", "devbrain");
+ Assert.NotNull(saved);
+ Assert.Equal("draft v2 once", saved.Content);
+ }
+
+ private sealed class FakeDocumentStore : IDocumentStore
+ {
+ private readonly Dictionary<(string Key, string Project), BrainDocument> _documents = new();
+
+ public FakeDocumentStore(params BrainDocument[] documents)
+ {
+ foreach (var document in documents)
+ {
+ document.ContentHash ??= ContentHashing.ComputeSha256(document.Content);
+ document.ContentLength ??= document.Content.Length;
+ _documents[(document.Key, document.Project)] = Clone(document);
+ }
+ }
+
+ public Task UpsertAsync(BrainDocument document)
+ {
+ document.Id = string.IsNullOrEmpty(document.Id) ? document.Key.Replace('/', ':') : document.Id;
+ document.ContentHash = ContentHashing.ComputeSha256(document.Content);
+ document.ContentLength = document.Content.Length;
+ _documents[(document.Key, document.Project)] = Clone(document);
+ return Task.FromResult(Clone(document));
+ }
+
+ public Task ReplaceIfHashMatchesAsync(BrainDocument document, string expectedContentHash)
+ {
+ if (!_documents.TryGetValue((document.Key, document.Project), out var existing))
+ {
+ return Task.FromResult(new ConditionalWriteResult(false, null, null, $"Document not found: '{document.Key}'"));
+ }
+
+ var currentHash = existing.ContentHash ?? ContentHashing.ComputeSha256(existing.Content);
+ if (!string.Equals(currentHash, expectedContentHash, StringComparison.OrdinalIgnoreCase))
+ {
+ return Task.FromResult(new ConditionalWriteResult(
+ false,
+ currentHash,
+ Clone(existing),
+ "Document changed since preview. Re-run PreviewEditDocument and try again."));
+ }
+
+ document.Id = existing.Id;
+ document.ContentHash = ContentHashing.ComputeSha256(document.Content);
+ document.ContentLength = document.Content.Length;
+ _documents[(document.Key, document.Project)] = Clone(document);
+
+ return Task.FromResult(new ConditionalWriteResult(
+ true,
+ currentHash,
+ Clone(document),
+ "Write applied."));
+ }
+
+ public Task GetAsync(string key, string project)
+ {
+ _documents.TryGetValue((key, project), out var document);
+ return Task.FromResult(document is null ? null : Clone(document));
+ }
+
+ public Task> ListAsync(string project, string? prefix = null) =>
+ Task.FromResult>([]);
+
+ public Task> SearchAsync(string query, string project) =>
+ Task.FromResult>([]);
+
+ public Task GetMetadataAsync(string key, string project)
+ {
+ _documents.TryGetValue((key, project), out var document);
+ if (document is null)
+ {
+ return Task.FromResult(null);
+ }
+
+ return Task.FromResult(new BrainDocument
+ {
+ Id = document.Id,
+ Key = document.Key,
+ Project = document.Project,
+ Tags = [.. document.Tags],
+ UpdatedAt = document.UpdatedAt,
+ UpdatedBy = document.UpdatedBy,
+ ContentHash = document.ContentHash,
+ ContentLength = document.ContentLength
+ });
+ }
+
+ public Task TouchAllAsync() => Task.FromResult(_documents.Count);
+
+ public Task DeleteAsync(string key, string project) =>
+ Task.FromResult(_documents.Remove((key, project)));
+
+ public Task AppendAsync(string key, string project, string content, string separator, string[] tags, string updatedBy) =>
+ throw new NotSupportedException();
+
+ public Task UpsertChunkAsync(string key, string project, string content, int chunkIndex, int totalChunks, string[] tags, string updatedBy) =>
+ throw new NotSupportedException();
+
+ private static BrainDocument Clone(BrainDocument document) =>
+ new()
+ {
+ Id = document.Id,
+ Key = document.Key,
+ Project = document.Project,
+ Content = document.Content,
+ Tags = [.. document.Tags],
+ UpdatedAt = document.UpdatedAt,
+ ContentHash = document.ContentHash,
+ ContentLength = document.ContentLength,
+ UpdatedBy = document.UpdatedBy,
+ Ttl = document.Ttl
+ };
+ }
+}