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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

All notable changes to DevBrain are tracked in this file. Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.9.0] — 2026-04-15

A new `EditTags` tool lets callers adjust tag metadata on an existing document without re-emitting its content. Previously the only way to add or drop a tag was a full `UpsertDocument` round-trip that re-sent the entire body — wasteful for large documents whose content isn't changing.

### Added
- **`EditTags(key, add?, remove?, project?)`** — applies a tag diff to an existing document. `add` and `remove` are disjoint lists; a tag present in both is rejected. Already-present tags in `add` are no-ops; absent tags in `remove` are silently ignored (idempotent). Content is never touched — the call updates `tags`, `updatedAt`, and `updatedBy` only. Empty `add` and `remove` returns a "nothing to do" response without a write. A no-effective-change result (e.g. adding a tag that's already present) also short-circuits without a write.
- **`ITagEditService` + `TagEditService`** — encapsulates tag-diff computation, conflict detection, and the write decision. Mirrors the `DocumentEditService` pattern so tag edits are unit-testable against a fake store without touching Cosmos.
- **`TagEditResult`** — structured response with `Found`, `Changed`, `PreviousTags`, `Tags`, `Added`, `Removed`, `UpdatedAt`, `UpdatedBy`, and a human-readable `Message`. `Added`/`Removed` reflect the tags that actually changed, not the raw input lists — so idempotent no-ops are visible to the caller.

### Changed
- `README.md` now documents the new `EditTags` tool and the "edit tags without re-upserting" workflow.
- `docs/seed/ref-devbrain-usage.md` now teaches AI callers when and how to use `EditTags` in place of a full upsert for tag-only changes.
- `host.json` MCP `instructions` updated to mention `EditTags`.
- `host.json` `serverVersion` and `DevBrain.Functions.csproj` `<Version>` bumped to `1.9.0`. (Note: both files had drifted at `1.7.0` through the 1.8.0 release — this bump catches them up.)

### Notes
- **Concurrency.** `EditTags` uses a read-then-upsert path rather than a hash-guarded conditional write. A concurrent tag edit between the read and write could be clobbered. This matches the existing `UpsertDocument` concurrency posture and is acceptable for tag metadata in DevBrain's single-tenant deployment. If stronger guarantees are ever needed, the path can be promoted to `ReplaceIfHashMatchesAsync` without changing the tool surface.
- **Input normalization.** Blank / whitespace-only tags in `add` or `remove` are dropped before diffing. Duplicates within a single list are deduplicated. Tag comparison is ordinal (case-sensitive) — `"Draft"` and `"draft"` are distinct tags.

## [1.8.0] — 2026-04-14

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.
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ All tools accept an optional `project` parameter (defaults to `"default"`) to is
| `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 |
| `EditTags` | `key` (required), `add`, `remove`, `project` | Add and/or remove tags on a document without re-emitting content. A tag in both `add` and `remove` is rejected. |
| `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. |
Expand Down Expand Up @@ -210,6 +211,21 @@ ApplyEditDocument(
)
```

### Editing Tags Without Re-Upserting

`EditTags` applies a tag diff to a document, leaving `content` untouched. Pass `add` and/or `remove` as disjoint lists — a tag that appears in both is rejected. Already-present tags in `add` are no-ops; absent tags in `remove` are silently ignored (idempotent). Both lists empty returns a "nothing to do" message without a write.

```text
EditTags(
key="ref:devbrain-usage",
project="default",
add=["workflow"],
remove=["draft"]
)
```

Use `EditTags` whenever you only need to adjust tag metadata — it avoids the overhead of sending the entire document body through `UpsertDocument`.

### 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
23 changes: 23 additions & 0 deletions docs/seed/ref-devbrain-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,29 @@ If the document changed after preview, apply refuses the write and tells you to
- 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

## Editing Tags Without Re-Upserting

To adjust tag metadata on an existing document, use `EditTags` instead of re-sending the full content through `UpsertDocument`.

### Add and/or remove tags in one call
```
EditTags(
key: "ref:devbrain-usage",
project: "default",
add: ["workflow"],
remove: ["draft"]
)
```

The server applies the diff:
- Tags in `add` that are already present are no-ops
- Tags in `remove` that are absent are ignored (idempotent)
- A tag that appears in both `add` and `remove` is rejected — the call fails without writing
- If `add` and `remove` are both empty, nothing is written
- If the resulting tag set is identical to the current one, nothing is written

`EditTags` never modifies the document `content`. It touches `tags`, `updatedAt`, and `updatedBy` only. Use this whenever you just need to label or un-label an existing document — it's dramatically cheaper than an `UpsertDocument` that has to re-emit the whole body.

## 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
18 changes: 12 additions & 6 deletions src/DevBrain.Functions/DevBrain.Functions.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<OutputType>Exe</OutputType>
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
<UserSecretsId>6221c643-6ba8-4163-98a2-2241c8ddd211</UserSecretsId>
<Version>1.7.0</Version>
<Version>1.9.0</Version>
</PropertyGroup>

<ItemGroup>
Expand All @@ -16,14 +16,20 @@
<PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.22.0" />
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.58.0" />
<PackageReference Include="Azure.Identity" Version="1.20.0" />
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="Microsoft.Extensions.Azure" Version="1.13.1" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="10.0.0" />
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.5.0" />
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Keys" Version="1.5.0" />
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.3.0" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.3.0" />
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.5.1" />
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Keys" Version="1.6.1" />
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.17.0" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.17.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<!-- Direct pin: Microsoft.AspNetCore.DataProtection 10.0.6 has a MAC-validation regression on
Linux (ManagedAuthenticatedEncryptor fails same-process Protect/Unprotect roundtrip), so we
stay on 10.0.0 for the runtime library. System.Security.Cryptography.Xml 10.0.5 and below
carry CVE-2026-33116 + CVE-2026-26171 (DoS), patched in 10.0.6 — pin directly here to floor
the transitive dep without pulling DataProtection forward. Revisit when 10.0.7+ ships. -->
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.6" />
</ItemGroup>

<ItemGroup>
Expand Down
1 change: 1 addition & 0 deletions src/DevBrain.Functions/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@

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

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

Expand Down
11 changes: 11 additions & 0 deletions src/DevBrain.Functions/Services/ITagEditService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace DevBrain.Functions.Services;

public interface ITagEditService
{
Task<TagEditResult> EditTagsAsync(
string key,
string project,
string[] add,
string[] remove,
string updatedBy);
}
16 changes: 16 additions & 0 deletions src/DevBrain.Functions/Services/TagEditResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace DevBrain.Functions.Services;

public sealed class TagEditResult
{
public string Key { get; init; } = string.Empty;
public string Project { get; init; } = string.Empty;
public bool Found { get; init; }
public bool Changed { get; init; }
public string[] PreviousTags { get; init; } = [];
public string[] Tags { get; init; } = [];
public string[] Added { get; init; } = [];
public string[] Removed { get; init; } = [];
public DateTimeOffset? UpdatedAt { get; init; }
public string? UpdatedBy { get; init; }
public string Message { get; init; } = string.Empty;
}
131 changes: 131 additions & 0 deletions src/DevBrain.Functions/Services/TagEditService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
using DevBrain.Functions.Models;

namespace DevBrain.Functions.Services;

public sealed class TagEditService : ITagEditService
{
private readonly IDocumentStore _store;

public TagEditService(IDocumentStore store)
{
_store = store;
}

public async Task<TagEditResult> EditTagsAsync(
string key,
string project,
string[] add,
string[] remove,
string updatedBy)
{
var addSet = Normalize(add);
var removeSet = Normalize(remove);

var conflicts = addSet.Intersect(removeSet, StringComparer.Ordinal).ToArray();
if (conflicts.Length > 0)
{
return new TagEditResult
{
Key = key,
Project = project,
Message = $"Tag(s) appear in both 'add' and 'remove': {string.Join(", ", conflicts)}. A tag cannot be added and removed in the same call."
};
}

if (addSet.Length == 0 && removeSet.Length == 0)
{
return new TagEditResult
{
Key = key,
Project = project,
Message = "'add' and 'remove' are both empty — nothing to do."
};
}

var document = await _store.GetAsync(key, project);
if (document is null)
{
return new TagEditResult
{
Key = key,
Project = project,
Found = false,
Message = $"Document not found: '{key}'"
};
}

var existing = document.Tags ?? [];
var removeLookup = new HashSet<string>(removeSet, StringComparer.Ordinal);
var existingLookup = new HashSet<string>(existing, StringComparer.Ordinal);

var resulting = new List<string>(existing.Length + addSet.Length);
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var tag in existing)
{
if (removeLookup.Contains(tag)) continue;
if (seen.Add(tag)) resulting.Add(tag);
}
foreach (var tag in addSet)
{
if (seen.Add(tag)) resulting.Add(tag);
}
var newTags = resulting.ToArray();

var actuallyAdded = addSet.Where(t => !existingLookup.Contains(t)).ToArray();
var actuallyRemoved = removeSet.Where(existingLookup.Contains).ToArray();

if (actuallyAdded.Length == 0 && actuallyRemoved.Length == 0)
{
return new TagEditResult
{
Key = document.Key,
Project = document.Project,
Found = true,
Changed = false,
PreviousTags = existing,
Tags = existing,
UpdatedAt = document.UpdatedAt,
UpdatedBy = document.UpdatedBy,
Message = "No tag changes needed — document already matches the requested state."
};
}

var updated = new BrainDocument
{
Id = document.Id,
Key = document.Key,
Project = document.Project,
Content = document.Content,
Tags = newTags,
UpdatedAt = DateTimeOffset.UtcNow,
UpdatedBy = updatedBy,
Ttl = document.Ttl
};

var saved = await _store.UpsertAsync(updated);

return new TagEditResult
{
Key = saved.Key,
Project = saved.Project,
Found = true,
Changed = true,
PreviousTags = existing,
Tags = saved.Tags,
Added = actuallyAdded,
Removed = actuallyRemoved,
UpdatedAt = saved.UpdatedAt,
UpdatedBy = saved.UpdatedBy,
Message = $"Tags updated ({actuallyAdded.Length} added, {actuallyRemoved.Length} removed)."
};
}

private static string[] Normalize(string[]? tags)
{
if (tags is null || tags.Length == 0) return [];
return tags
.Where(t => !string.IsNullOrWhiteSpace(t))
.Distinct(StringComparer.Ordinal)
.ToArray();
}
}
41 changes: 40 additions & 1 deletion src/DevBrain.Functions/Tools/DocumentTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ public sealed class DocumentTools
{
private readonly IDocumentStore _store;
private readonly IDocumentEditService _editService;
private readonly ITagEditService _tagEditService;

public DocumentTools(IDocumentStore store, IDocumentEditService editService)
public DocumentTools(IDocumentStore store, IDocumentEditService editService, ITagEditService tagEditService)
{
_store = store;
_editService = editService;
_tagEditService = tagEditService;
}

[Function(nameof(UpsertDocument))]
Expand Down Expand Up @@ -484,6 +486,43 @@ public async Task<string> SearchDocuments(
}
}

[Function(nameof(EditTags))]
public async Task<string> EditTags(
[McpToolTrigger("EditTags", "Add and/or remove tags on a document without re-emitting its content. Provide 'add' and/or 'remove' as disjoint tag lists; the server applies the diff and records updatedAt/updatedBy. Document content is untouched. A tag present in both 'add' and 'remove' is rejected.")]
ToolInvocationContext context,
[McpToolProperty("key", "Document key whose tags to edit.", isRequired: true)]
string key,
[McpToolProperty("add", "Tags to add. Already-present tags are kept as-is (no duplicates).")]
string[]? add,
[McpToolProperty("remove", "Tags to remove. Absent tags are ignored (idempotent).")]
string[]? remove,
[McpToolProperty("project", "Project scope (default: \"default\").")]
string? project,
FunctionContext functionContext)
{
var keyError = ValidateWriteKey(key);
if (keyError is not null)
{
return keyError;
}

try
{
var result = await _tagEditService.EditTagsAsync(
key,
project ?? "default",
add ?? [],
remove ?? [],
GetCallerIdentity(functionContext));

return JsonSerializer.Serialize(result);
}
catch (Exception ex)
{
return $"Error editing tags: {ex.Message}";
}
}

/// <summary>
/// Enforces the colon-key convention on write paths. Writes that use '/' as a separator
/// collide on id (EncodeId maps '/' → ':') but land in a different partition (raw key),
Expand Down
4 changes: 2 additions & 2 deletions src/DevBrain.Functions/host.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@
"routePrefix": ""
},
"mcp": {
"instructions": "DevBrain is a developer knowledge store. Use UpsertDocument to save sprint docs, architecture notes, or project state. Use GetDocument to retrieve by key. Use ListDocuments to browse what's stored. Use SearchDocuments to find content by keyword. Use DeleteDocument to remove a document. Use AppendDocument for append-only logs (session history, decision logs) where re-emitting the whole body is wasteful. Use UpsertDocumentChunked when a document is too large to emit in a single turn. Use GetDocumentMetadata to check if a document exists and inspect its size, hash, and timestamps without reading the full content. Use CompareDocument to check whether candidate content matches a stored document (by raw content or SHA-256 hash) before deciding whether to import or update. Keys use colon as separator: state:current, sprint:my-feature, ref:notes. Writes require colon keys.",
"instructions": "DevBrain is a developer knowledge store. Use UpsertDocument to save sprint docs, architecture notes, or project state. Use GetDocument to retrieve by key. Use ListDocuments to browse what's stored. Use SearchDocuments to find content by keyword. Use DeleteDocument to remove a document. Use AppendDocument for append-only logs (session history, decision logs) where re-emitting the whole body is wasteful. Use UpsertDocumentChunked when a document is too large to emit in a single turn. Use GetDocumentMetadata to check if a document exists and inspect its size, hash, and timestamps without reading the full content. Use CompareDocument to check whether candidate content matches a stored document (by raw content or SHA-256 hash) before deciding whether to import or update. Use PreviewEditDocument and ApplyEditDocument to make safe exact-text edits without re-emitting the whole document. Use EditTags to add and/or remove tags on a document without re-emitting its content. Keys use colon as separator: state:current, sprint:my-feature, ref:notes. Writes require colon keys.",
"serverName": "DevBrain",
"serverVersion": "1.7.0",
"serverVersion": "1.9.0",
"system": {
"webhookAuthorizationLevel": "anonymous"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.3.0" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.5.0" />
</ItemGroup>

<ItemGroup>
Expand Down
Loading
Loading