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
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<!-- Version can be overridden from the command line: -p:Version=0.3.1
AssemblyVersion and FileVersion are derived automatically by the SDK
(prerelease suffixes like -beta001 are stripped for assembly versions). -->
<Version>0.14.10</Version>
<Version>0.14.11</Version>
</PropertyGroup>

<!-- NuGet package metadata (shared across all packable projects) -->
Expand Down
1 change: 1 addition & 0 deletions RockBot.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
<Project Path="tests/RockBot.UserProxy.Tests/RockBot.UserProxy.Tests.csproj" />
<Project Path="tests/RockBot.Cli.Tests/RockBot.Cli.Tests.csproj" />
<Project Path="tests/RockBot.Agent.Tests/RockBot.Agent.Tests.csproj" />
<Project Path="tests/RockBot.Tools.FileSystem.Tests/RockBot.Tools.FileSystem.Tests.csproj" />
<Project Path="tests/RockBot.Tools.Web.Tests/RockBot.Tools.Web.Tests.csproj" />
<Project Path="tests/RockBot.Subagent.Tests/RockBot.Subagent.Tests.csproj" />
<Project Path="tests/RockBot.Wisp.Tests/RockBot.Wisp.Tests.csproj" />
Expand Down
21 changes: 21 additions & 0 deletions deploy/helm/rockbot/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,24 @@ Name of the shared volume PVC.
{{- define "rockbot.sharedPvcName" -}}
{{- include "rockbot.fullname" . }}-shared
{{- end }}

{{/*
find(1) exclusion clauses for shared.protectedPaths, one pair of lines per entry.

Each entry is stripped of surrounding slashes first: a trailing one would render
'.../notes//*', which fnmatch cannot match against '.../notes/x.md', silently
disabling the protection the operator asked for. Two clauses per entry so a
protected leaf file is covered as well as a directory's contents.

Emitted without indentation — callers apply `nindent` and must guard on the result
being empty, since a blank continuation line would break the shell command.
*/}}
{{- define "rockbot.sharedProtectedFindClauses" -}}
{{- range .Values.shared.protectedPaths }}
{{- $prefix := . | trimPrefix "/" | trimSuffix "/" }}
{{- if $prefix }}
! -path '/rockbot/shared/{{ $prefix }}' \
! -path '/rockbot/shared/{{ $prefix }}/*' \
{{- end }}
{{- end }}
{{- end }}
25 changes: 21 additions & 4 deletions deploy/helm/rockbot/templates/shared/cronjob.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,30 @@ spec:
- sh
- -c
- |
{{- $protected := include "rockbot.sharedProtectedFindClauses" . | trim }}
mkdir -p /rockbot/shared/tmp /rockbot/shared/drafts /rockbot/shared/exports
find /rockbot/shared/tmp -mindepth 1 -mtime +{{ .Values.shared.tmpTtlDays }} -delete
find /rockbot/shared/drafts -mindepth 1 -mtime +{{ .Values.shared.draftsTtlDays }} -delete
find /rockbot/shared/exports -mindepth 1 -mtime +{{ .Values.shared.exportsTtlDays }} -delete
# Per-directory sweeps. Prefixes in shared.protectedPaths are exempt
# from these too: the setting reads as a global promise, and an operator
# who protects a path that happens to sit under drafts/ would otherwise
# get no protection at all with nothing to indicate why.
# `!` rather than `-not`: busybox find does not reliably provide the
# GNU spelling.
find /rockbot/shared/tmp -mindepth 1 -mtime +{{ .Values.shared.tmpTtlDays }} \
{{- if $protected }}{{ $protected | nindent 20 }}{{ end }}
-delete
find /rockbot/shared/drafts -mindepth 1 -mtime +{{ .Values.shared.draftsTtlDays }} \
{{- if $protected }}{{ $protected | nindent 20 }}{{ end }}
-delete
find /rockbot/shared/exports -mindepth 1 -mtime +{{ .Values.shared.exportsTtlDays }} \
{{- if $protected }}{{ $protected | nindent 20 }}{{ end }}
-delete
# Catch-all: any remaining file anywhere on the shared volume older than
# globalTtlDays. Uses -type f so directory structure is preserved.
find /rockbot/shared -mindepth 1 -type f -mtime +{{ .Values.shared.globalTtlDays }} -delete
# This sweep keys on mtime, so durable content that is legitimately not
# edited for a long time is exactly what it would delete.
find /rockbot/shared -mindepth 1 -type f -mtime +{{ .Values.shared.globalTtlDays }} \
{{- if $protected }}{{ $protected | nindent 20 }}{{ end }}
-delete
volumeMounts:
- name: shared-data
mountPath: /rockbot/shared
Expand Down
13 changes: 13 additions & 0 deletions deploy/helm/rockbot/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,19 @@ shared:
# not touched, so empty structure is preserved. Safety net for ad-hoc paths
# (e.g. patrol drafts, subagent artifacts) that don't have a dedicated TTL.
globalTtlDays: 30
# Path prefixes (relative to the volume root) exempt from the catch-all sweep.
# The sweep keys on mtime, so a file is deleted for not having changed recently —
# which is the normal state of durable content such as reference corpora, campaign
# canon, or any document set the agent reads far more often than it edits. Anything
# that must survive longer than globalTtlDays without an edit belongs here.
# Matching is a glob on the full path, so nested content (including a `.git`
# directory) under a listed prefix is protected too. A single file may be listed
# as well as a directory. Leading and trailing slashes are stripped, so `canon`,
# `/canon`, and `canon/` are equivalent. Entries are exempt from every sweep,
# the per-directory ones above included.
# protectedPaths:
# - canon
protectedPaths: []
# Shared POSIX group ID applied via pod-level securityContext.fsGroup on every
# pod that mounts the rockbot-shared PVC (agent, shared-cleanup cronjob,
# ephemeral script pods). kubelet chgrp's the volume root to this GID, sets
Expand Down
254 changes: 254 additions & 0 deletions src/RockBot.Host/TextEdit.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
namespace RockBot.Host;

/// <summary>
/// Outcome classification for an <see cref="TextEdit.Apply"/> call.
/// </summary>
public enum TextEditStatus
{
/// <summary>The edit was applied.</summary>
Success,

/// <summary><c>oldText</c> does not occur in the content.</summary>
NotFound,

/// <summary>
/// <c>oldText</c> occurs more than once and <c>replaceAll</c> was not set.
/// The caller must supply more surrounding context to disambiguate.
/// </summary>
Ambiguous,

/// <summary><c>oldText</c> was empty — an empty match has no well-defined location.</summary>
EmptyOldText,

/// <summary><c>oldText</c> and <c>newText</c> are identical, so the edit is a no-op.</summary>
NoChange,
}

/// <summary>
/// Result of an exact-match text edit.
/// </summary>
/// <param name="Status">Outcome classification.</param>
/// <param name="Content">
/// The edited content when <see cref="Status"/> is <see cref="TextEditStatus.Success"/>;
/// <c>null</c> otherwise.
/// </param>
/// <param name="ReplacementCount">Number of occurrences replaced. Zero unless successful.</param>
/// <param name="Error">Human-readable failure description; <c>null</c> on success.</param>
public readonly record struct TextEditResult(
TextEditStatus Status,
string? Content,
int ReplacementCount,
string? Error)
{
/// <summary>Whether the edit succeeded.</summary>
public bool IsSuccess => Status == TextEditStatus.Success;
}

/// <summary>
/// Exact-match text replacement — the shared primitive behind surgical edits to
/// files, memory entries, and skill bodies.
/// </summary>
/// <remarks>
/// <para>
/// Every RockBot write surface historically replaced its entire payload: a one-word
/// correction to a document meant re-emitting the whole document, and anything the
/// model failed to reproduce was silently lost. This primitive exists so a caller can
/// state the change instead of restating the content.
/// </para>
/// <para>
/// Matching is ordinal and exact. Ambiguity is an error rather than a guess: when
/// <c>oldText</c> occurs more than once the caller must either widen the match with
/// surrounding context or opt in to <c>replaceAll</c>. That refusal is the point —
/// a tool that silently edits the first of several matches is worse than one that
/// declines, because the caller cannot tell which one it hit.
/// </para>
/// </remarks>
public static class TextEdit
{
/// <summary>
/// Replaces <paramref name="oldText"/> with <paramref name="newText"/> in
/// <paramref name="content"/>.
/// </summary>
/// <param name="content">The content to edit.</param>
/// <param name="oldText">Exact text to find. Must be non-empty.</param>
/// <param name="newText">Replacement text. May be empty to delete.</param>
/// <param name="replaceAll">
/// When <c>true</c>, replaces every occurrence. When <c>false</c> (default), more
/// than one occurrence is an error.
/// </param>
/// <returns>A <see cref="TextEditResult"/> describing the outcome.</returns>
/// <remarks>
/// <para>
/// When <paramref name="oldText"/> does not match as supplied, the match is retried
/// with its line endings converted — bare LFs to CRLF, then CRLFs to bare LF. This
/// lets a caller edit a document without having to know its line-ending style, in
/// either direction.
/// </para>
/// <para>
/// <paramref name="newText"/> is converted to the line-ending style
/// <paramref name="content"/> already uses — on the exact-match path as well as the
/// retry path — so an edit cannot leave a single-style document with mixed endings.
/// Content that is already mixed is left alone, having no style to preserve.
/// </para>
/// </remarks>
public static TextEditResult Apply(
string content,
string oldText,
string newText,
bool replaceAll = false)
{
ArgumentNullException.ThrowIfNull(content);
ArgumentNullException.ThrowIfNull(oldText);
ArgumentNullException.ThrowIfNull(newText);

if (oldText.Length == 0)
{
return new TextEditResult(
TextEditStatus.EmptyOldText,
null,
0,
"oldText must not be empty — an empty match has no well-defined location. " +
"To append content, include the trailing text you want to insert before.");
}

if (string.Equals(oldText, newText, StringComparison.Ordinal))
{
return new TextEditResult(
TextEditStatus.NoChange,
null,
0,
"oldText and newText are identical — the edit would change nothing.");
}

var effectiveOld = oldText;
var effectiveNew = MatchNewlineStyle(newText, content);
var count = CountOccurrences(content, effectiveOld);

// The caller's line endings do not match the file's. Retry with each conversion
// in turn — LF-supplied text against a CRLF file, and CRLF-supplied text against
// an LF file — so line-ending style is not something the caller has to discover
// by trial and error.
if (count == 0)
{
(string Old, string New)[] candidates =
[
(ToCrLf(oldText), ToCrLf(newText)),
(ToLf(oldText), ToLf(newText)),
];

foreach (var candidate in candidates)
{
if (string.Equals(candidate.Old, oldText, StringComparison.Ordinal))
continue;

var candidateCount = CountOccurrences(content, candidate.Old);
if (candidateCount == 0)
continue;

effectiveOld = candidate.Old;
effectiveNew = candidate.New;
count = candidateCount;
break;
}
}

if (count == 0)
{
return new TextEditResult(
TextEditStatus.NotFound,
null,
0,
"oldText was not found. It must match the content exactly, including " +
"whitespace and indentation. Read the current content and copy the text verbatim.");
}

// Newline normalization can collapse a difference that the raw arguments had —
// "a\r\nb" replaced by "a\nb" in a CRLF file asks for no change at all.
if (string.Equals(effectiveOld, effectiveNew, StringComparison.Ordinal))
{
return new TextEditResult(
TextEditStatus.NoChange,
null,
0,
"oldText and newText differ only in line endings, which are normalized to " +
"the style the content already uses — the edit would change nothing.");
}

if (count > 1 && !replaceAll)
{
return new TextEditResult(
TextEditStatus.Ambiguous,
null,
0,
$"oldText occurs {count} times — the edit target is ambiguous. Either include " +
"more surrounding text so the match is unique, or set replaceAll to change every occurrence.");
}

var edited = replaceAll
? content.Replace(effectiveOld, effectiveNew, StringComparison.Ordinal)
: ReplaceFirst(content, effectiveOld, effectiveNew);

return new TextEditResult(TextEditStatus.Success, edited, replaceAll ? count : 1, null);
}

/// <summary>
/// Counts non-overlapping ordinal occurrences of <paramref name="needle"/>.
/// </summary>
private static int CountOccurrences(string haystack, string needle)
{
var count = 0;
var index = 0;

while ((index = haystack.IndexOf(needle, index, StringComparison.Ordinal)) >= 0)
{
count++;
index += needle.Length;
}

return count;
}

private static string ReplaceFirst(string content, string oldText, string newText)
{
var index = content.IndexOf(oldText, StringComparison.Ordinal);
return index < 0
? content
: string.Concat(content.AsSpan(0, index), newText, content.AsSpan(index + oldText.Length));
}

/// <summary>
/// Converts <paramref name="value"/> to the line-ending style
/// <paramref name="content"/> uses, or returns it unchanged when the content has no
/// single style to preserve.
/// </summary>
private static string MatchNewlineStyle(string value, string content)
{
if (!value.Contains('\n', StringComparison.Ordinal))
return value;

var crLf = CountOccurrences(content, "\r\n");
var bareLf = CountOccurrences(content, "\n") - crLf;

if (crLf > 0 && bareLf == 0)
return ToCrLf(value);

if (bareLf > 0 && crLf == 0)
return ToLf(value);

// Mixed endings, or none at all — no style to conform to.
return value;
}

/// <summary>
/// Converts bare LF line endings to CRLF, leaving existing CRLF pairs intact.
/// </summary>
private static string ToCrLf(string value) =>
value.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace("\n", "\r\n", StringComparison.Ordinal);

/// <summary>
/// Converts CRLF line endings to bare LF.
/// </summary>
private static string ToLf(string value) =>
value.Replace("\r\n", "\n", StringComparison.Ordinal);
}
Loading
Loading