Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions NuGet.Config
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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="<hash from preview>"
)
```

### When to use Append vs Chunked

Both tools exist to work around the LLM-client per-turn output budget, but they solve different problems:
Expand Down
42 changes: 41 additions & 1 deletion docs/seed/ref-devbrain-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: "<hash from preview>"
)
```
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.
Expand Down
7 changes: 7 additions & 0 deletions src/DevBrain.Functions/Models/ConditionalWriteResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace DevBrain.Functions.Models;

public sealed record ConditionalWriteResult(
bool Applied,
string? CurrentContentHash,
BrainDocument? Document,
string Message);
26 changes: 26 additions & 0 deletions src/DevBrain.Functions/Models/EditApplyResult.cs
Original file line number Diff line number Diff line change
@@ -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;
}
30 changes: 30 additions & 0 deletions src/DevBrain.Functions/Models/EditPreviewResult.cs
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions src/DevBrain.Functions/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
});

builder.Services.AddSingleton<IDocumentStore, CosmosDocumentStore>();
builder.Services.AddSingleton<IDocumentEditService, DocumentEditService>();

// ---------------- OAuth DCR facade (v1.6) ----------------

Expand Down
22 changes: 22 additions & 0 deletions src/DevBrain.Functions/Services/ContentHashing.cs
Original file line number Diff line number Diff line change
@@ -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);
}

/// <summary>
/// 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.
/// </summary>
public static string NormalizeForHash(string content) =>
content.ReplaceLineEndings("\n").TrimEnd();
}
98 changes: 82 additions & 16 deletions src/DevBrain.Functions/Services/CosmosDocumentStore.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -35,7 +33,7 @@ public CosmosDocumentStore(CosmosClient cosmosClient, IConfiguration configurati
public async Task<BrainDocument> 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,
Expand All @@ -45,20 +43,88 @@ public async Task<BrainDocument> UpsertAsync(BrainDocument document)

private static string EncodeId(string key) => key.Replace('/', ':');

private static string ComputeSha256(string content)
public async Task<ConditionalWriteResult> 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);

/// <summary>
/// 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.
/// </summary>
private static string NormalizeForHash(string content) =>
content.ReplaceLineEndings("\n").TrimEnd();
using var iterator = _container.GetItemQueryIterator<BrainDocument>(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<BrainDocument> currentResponse;
try
{
currentResponse = await _container.ReadItemAsync<BrainDocument>(
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<BrainDocument?> GetAsync(string key, string project)
{
Expand Down Expand Up @@ -316,7 +382,7 @@ public async Task<BrainDocument> AppendAsync(
Tags = UnionTags(existing.Tags, tags),
UpdatedAt = DateTimeOffset.UtcNow,
UpdatedBy = updatedBy,
ContentHash = ComputeSha256(mergedContent),
ContentHash = ContentHashing.ComputeSha256(mergedContent),
ContentLength = mergedContent.Length
};

Expand Down
Loading
Loading