diff --git a/src/RockBot.Agent/agent/common-directives.md b/src/RockBot.Agent/agent/common-directives.md
index 0fa107d..30d7d1a 100644
--- a/src/RockBot.Agent/agent/common-directives.md
+++ b/src/RockBot.Agent/agent/common-directives.md
@@ -179,6 +179,12 @@ Call `search_memory` explicitly only when you want to search with a specific
query that differs from the raw message (e.g., after clarification, or when you
want to narrow to a category).
+`search_memory` has two modes. Use `mode='regex'` when you know the literal
+token you're hunting for — a file path, ID, version string, or exact phrase —
+and the regex matches against both the memory's path name (`category/id`) and
+its content. Otherwise leave the default `mode='hybrid'` for semantic/keyword
+search.
+
### Narrative identity
Your evolving self-model is stored in long-term memory under `agent-identity/`
diff --git a/src/RockBot.Host.Abstractions/MemorySearchCriteria.cs b/src/RockBot.Host.Abstractions/MemorySearchCriteria.cs
index 95483d9..9add1f0 100644
--- a/src/RockBot.Host.Abstractions/MemorySearchCriteria.cs
+++ b/src/RockBot.Host.Abstractions/MemorySearchCriteria.cs
@@ -1,25 +1,37 @@
-namespace RockBot.Host;
-
-///
-/// Criteria for searching long-term memory entries.
-/// All specified criteria are combined with AND logic.
-///
-/// Case-insensitive substring to match against content.
-/// Category prefix to match (e.g. "project-context" matches "project-context/rockbot").
-/// Tags that entries must contain (all specified tags must be present).
-/// Only include entries created after this time.
-/// Only include entries created before this time.
-/// Maximum number of results to return. Defaults to 20.
-///
-/// Pre-computed query embedding vector. When provided, stores skip generating their own
-/// query embedding — avoiding redundant calls to the embedding endpoint when multiple
-/// searches share the same query text (e.g. during context building).
-///
-public sealed record MemorySearchCriteria(
- string? Query = null,
- string? Category = null,
- IReadOnlyList? Tags = null,
- DateTimeOffset? CreatedAfter = null,
- DateTimeOffset? CreatedBefore = null,
- int MaxResults = 20,
- float[]? QueryEmbedding = null);
+namespace RockBot.Host;
+
+///
+/// Criteria for searching long-term memory entries.
+/// All specified criteria are combined with AND logic.
+///
+///
+/// In (default), a case-insensitive keyword/phrase to rank against.
+/// In , a .NET regex pattern matched against the entry's
+/// memory path name ({category}/{id} or {id}) plus its content, tags, and category words.
+///
+/// Category prefix to match (e.g. "project-context" matches "project-context/rockbot").
+/// Tags that entries must contain (all specified tags must be present).
+/// Only include entries created after this time.
+/// Only include entries created before this time.
+/// Maximum number of results to return. Defaults to 20.
+///
+/// Pre-computed query embedding vector. When provided, stores skip generating their own
+/// query embedding — avoiding redundant calls to the embedding endpoint when multiple
+/// searches share the same query text (e.g. during context building). Ignored in
+/// .
+///
+/// Search backend selector. Defaults to .
+///
+/// When is , controls case sensitivity of the regex.
+/// Default false mirrors Claude Code's Grep tool. Ignored in hybrid mode.
+///
+public sealed record MemorySearchCriteria(
+ string? Query = null,
+ string? Category = null,
+ IReadOnlyList? Tags = null,
+ DateTimeOffset? CreatedAfter = null,
+ DateTimeOffset? CreatedBefore = null,
+ int MaxResults = 20,
+ float[]? QueryEmbedding = null,
+ MemorySearchMode Mode = MemorySearchMode.Hybrid,
+ bool RegexCaseSensitive = false);
diff --git a/src/RockBot.Host.Abstractions/MemorySearchException.cs b/src/RockBot.Host.Abstractions/MemorySearchException.cs
new file mode 100644
index 0000000..7638130
--- /dev/null
+++ b/src/RockBot.Host.Abstractions/MemorySearchException.cs
@@ -0,0 +1,14 @@
+namespace RockBot.Host;
+
+///
+/// Thrown by memory search backends when a caller-supplied query cannot be executed —
+/// for example an invalid regex pattern, a per-entry match timeout, or an overall
+/// scan-budget overrun. The tool layer surfaces the message verbatim to the model so
+/// it can refine its query.
+///
+public sealed class MemorySearchException : Exception
+{
+ public MemorySearchException(string message) : base(message) { }
+
+ public MemorySearchException(string message, Exception inner) : base(message, inner) { }
+}
diff --git a/src/RockBot.Host.Abstractions/MemorySearchMode.cs b/src/RockBot.Host.Abstractions/MemorySearchMode.cs
new file mode 100644
index 0000000..df630ff
--- /dev/null
+++ b/src/RockBot.Host.Abstractions/MemorySearchMode.cs
@@ -0,0 +1,21 @@
+namespace RockBot.Host;
+
+///
+/// Backend used by when scoring candidate entries
+/// against a query.
+///
+public enum MemorySearchMode
+{
+ ///
+ /// Default. BM25 keyword ranking, optionally combined with vector similarity when
+ /// embeddings are configured. Recall-oriented and tolerant of paraphrase.
+ ///
+ Hybrid = 0,
+
+ ///
+ /// .NET regex pattern matched against the literal stored content (memory path name +
+ /// content + tags + category words). Exact and deterministic — preferred when the
+ /// caller already knows the literal token (file path, id, version, exact phrase).
+ ///
+ Regex = 1,
+}
diff --git a/src/RockBot.Host/FileMemoryStore.cs b/src/RockBot.Host/FileMemoryStore.cs
index 01da3f2..1fc4a58 100644
--- a/src/RockBot.Host/FileMemoryStore.cs
+++ b/src/RockBot.Host/FileMemoryStore.cs
@@ -107,7 +107,7 @@ public async Task> SearchAsync(MemorySearchCriteria c
// No query: return most-recently reinforced entries up to MaxResults.
// Ordered by LastSeenAt (real reinforcement) rather than UpdatedAt (dream rewrites),
// so dream housekeeping does not artificially promote entries in no-query results.
- if (criteria.Query is null)
+ if (string.IsNullOrWhiteSpace(criteria.Query))
{
return candidates
.OrderByDescending(e => e.LastSeenAt)
@@ -115,6 +115,17 @@ public async Task> SearchAsync(MemorySearchCriteria c
.ToList();
}
+ // Regex mode: literal pattern matching, no scoring, bounded by timeouts.
+ if (criteria.Mode == MemorySearchMode.Regex)
+ {
+ return RegexMatcher.MatchEntries(
+ candidates,
+ criteria.Query,
+ criteria.RegexCaseSensitive,
+ criteria.MaxResults,
+ BuildRegexSurface);
+ }
+
// With query: use hybrid ranking if embeddings available, else BM25-only.
if (_embeddingCache is not null)
{
@@ -263,6 +274,21 @@ internal static string GetDocumentText(MemoryEntry entry)
return string.Join(" ", parts);
}
+ // ── Regex match surface ───────────────────────────────────────────────────
+
+ ///
+ /// Returns the text the regex backend matches against: the entry's logical memory
+ /// path name ({category}/{id} or {id} when uncategorized) on its own
+ /// line, then the BM25 document text (content + tags + category words). The on-disk
+ /// file path from is deliberately never included — the
+ /// model interacts with memories by id, not by storage layout.
+ ///
+ internal static string BuildRegexSurface(MemoryEntry entry)
+ {
+ var pathName = entry.Category is null ? entry.Id : $"{entry.Category}/{entry.Id}";
+ return $"{pathName}\n{GetDocumentText(entry)}";
+ }
+
// ── Structural Filter ─────────────────────────────────────────────────────
private static bool PassesStructuralFilters(MemoryEntry entry, MemorySearchCriteria criteria)
diff --git a/src/RockBot.Host/RegexMatcher.cs b/src/RockBot.Host/RegexMatcher.cs
new file mode 100644
index 0000000..3d4e629
--- /dev/null
+++ b/src/RockBot.Host/RegexMatcher.cs
@@ -0,0 +1,87 @@
+using System.Diagnostics;
+using System.Text.RegularExpressions;
+
+namespace RockBot.Host;
+
+///
+/// Runs a caller-supplied regex pattern across a pre-filtered set of
+/// candidates. Used by the regex backend of .
+/// Bounds cost two ways: a per-entry catches catastrophic
+/// backtracking on a single input, and an overall wall-clock budget across the scan stops a
+/// slow-but-not-pathological pattern from dominating as the corpus grows.
+///
+internal static class RegexMatcher
+{
+ internal static readonly TimeSpan DefaultPerEntryTimeout = TimeSpan.FromSeconds(1);
+ internal static readonly TimeSpan DefaultOverallBudget = TimeSpan.FromSeconds(10);
+ internal const int MaxPatternLength = 512;
+
+ public static IReadOnlyList MatchEntries(
+ IReadOnlyCollection candidates,
+ string pattern,
+ bool caseSensitive,
+ int maxResults,
+ Func documentText) =>
+ MatchEntries(candidates, pattern, caseSensitive, maxResults, documentText,
+ DefaultPerEntryTimeout, DefaultOverallBudget);
+
+ ///
+ /// Test-friendly overload that allows custom timeouts. Production callers should use
+ /// the parameterless-budget overload above.
+ ///
+ internal static IReadOnlyList MatchEntries(
+ IReadOnlyCollection candidates,
+ string pattern,
+ bool caseSensitive,
+ int maxResults,
+ Func documentText,
+ TimeSpan perEntryTimeout,
+ TimeSpan overallBudget)
+ {
+ if (pattern.Length > MaxPatternLength)
+ throw new MemorySearchException(
+ $"Regex pattern exceeds {MaxPatternLength} characters. Narrow the pattern.");
+
+ Regex regex;
+ var options = RegexOptions.CultureInvariant;
+ if (!caseSensitive) options |= RegexOptions.IgnoreCase;
+ try
+ {
+ regex = new Regex(pattern, options, perEntryTimeout);
+ }
+ catch (ArgumentException ex)
+ {
+ throw new MemorySearchException($"Invalid regex pattern: {ex.Message}", ex);
+ }
+
+ var matches = new List();
+ var sw = Stopwatch.StartNew();
+ var scanned = 0;
+ foreach (var entry in candidates)
+ {
+ if (sw.Elapsed > overallBudget)
+ throw new MemorySearchException(
+ $"Regex search exceeded {overallBudget.TotalSeconds:F1}s after scanning " +
+ $"{scanned}/{candidates.Count} entries. Narrow the pattern or add a category filter.");
+
+ scanned++;
+ try
+ {
+ if (regex.IsMatch(documentText(entry)))
+ matches.Add(entry);
+ }
+ catch (RegexMatchTimeoutException ex)
+ {
+ throw new MemorySearchException(
+ $"Regex match timed out after {perEntryTimeout.TotalSeconds:F1}s on a single entry. " +
+ "Try a more specific pattern.", ex);
+ }
+ }
+
+ return matches
+ .OrderByDescending(e => e.ImportanceScore)
+ .ThenByDescending(e => e.LastSeenAt)
+ .Take(maxResults)
+ .ToList();
+ }
+}
diff --git a/src/RockBot.Memory/MemoryTools.cs b/src/RockBot.Memory/MemoryTools.cs
index a2d9e93..c0c04d5 100644
--- a/src/RockBot.Memory/MemoryTools.cs
+++ b/src/RockBot.Memory/MemoryTools.cs
@@ -126,18 +126,34 @@ public Task SaveMemory(
}
[Description("Search long-term memory for previously saved facts, preferences, or patterns. " +
- "Use query for keyword search and category for scoping to a knowledge area.")]
+ "Use query for keyword search and category for scoping to a knowledge area. " +
+ "Set mode='regex' when you know the literal token (file path, id, version, exact phrase); " +
+ "otherwise leave mode='hybrid' (default) for semantic/keyword search.")]
public async Task SearchMemory(
- [Description("Optional keyword to search for in memory content")] string? query = null,
- [Description("Optional category prefix to filter by (e.g. 'user-preferences')")] string? category = null)
+ [Description("Optional keyword (hybrid mode) or .NET regex pattern (regex mode) to search for")] string? query = null,
+ [Description("Optional category prefix to filter by (e.g. 'user-preferences')")] string? category = null,
+ [Description("Search backend: 'hybrid' (default, BM25 + optional vector) or 'regex' (case-insensitive .NET regex against memory path name and content)")] string? mode = null)
{
- _logger.LogInformation("Tool call: SearchMemory(query={Query}, category={Category})", query, category);
+ _logger.LogInformation("Tool call: SearchMemory(query={Query}, category={Category}, mode={Mode})", query, category, mode);
+
+ if (!TryParseMode(mode, out var parsedMode))
+ return $"Unknown search mode '{mode}'. Use 'hybrid' or 'regex'.";
var criteria = new MemorySearchCriteria(
Query: string.IsNullOrWhiteSpace(query) ? null : query.Trim(),
- Category: string.IsNullOrWhiteSpace(category) ? null : category.Trim());
+ Category: string.IsNullOrWhiteSpace(category) ? null : category.Trim(),
+ Mode: parsedMode);
- var results = await _memory.SearchAsync(criteria);
+ IReadOnlyList results;
+ try
+ {
+ results = await _memory.SearchAsync(criteria);
+ }
+ catch (MemorySearchException ex)
+ {
+ _logger.LogInformation("SearchMemory rejected by backend: {Message}", ex.Message);
+ return ex.Message;
+ }
_logger.LogInformation("SearchMemory returned {Count} results", results.Count);
@@ -160,6 +176,21 @@ public async Task SearchMemory(
return sb.ToString();
}
+ private static bool TryParseMode(string? mode, out MemorySearchMode parsed)
+ {
+ if (string.IsNullOrWhiteSpace(mode))
+ {
+ parsed = MemorySearchMode.Hybrid;
+ return true;
+ }
+
+ if (Enum.TryParse(mode.Trim(), ignoreCase: true, out parsed))
+ return true;
+
+ parsed = MemorySearchMode.Hybrid;
+ return false;
+ }
+
private static string FormatAge(MemoryEntry e)
{
var now = DateTimeOffset.UtcNow;
diff --git a/tests/RockBot.Agent.Tests/MemoryToolsTests.cs b/tests/RockBot.Agent.Tests/MemoryToolsTests.cs
index 557ff39..6d6ca16 100644
--- a/tests/RockBot.Agent.Tests/MemoryToolsTests.cs
+++ b/tests/RockBot.Agent.Tests/MemoryToolsTests.cs
@@ -222,6 +222,72 @@ public async Task SearchMemory_ShowsImportanceScore()
StringAssert.Contains(result, "importance=0.85");
}
+ // -------------------------------------------------------------------------
+ // SearchMemory — mode parameter
+ // -------------------------------------------------------------------------
+
+ [TestMethod]
+ public async Task SearchMemory_DefaultMode_PassesHybrid()
+ {
+ var memory = new StubLongTermMemory();
+ var tools = MakeTools(memory);
+
+ await tools.SearchMemory("anything");
+
+ Assert.IsNotNull(memory.LastCriteria);
+ Assert.AreEqual(MemorySearchMode.Hybrid, memory.LastCriteria.Mode);
+ }
+
+ [TestMethod]
+ public async Task SearchMemory_ModeRegex_PassesRegex()
+ {
+ var memory = new StubLongTermMemory();
+ var tools = MakeTools(memory);
+
+ await tools.SearchMemory("anything", mode: "Regex");
+
+ Assert.IsNotNull(memory.LastCriteria);
+ Assert.AreEqual(MemorySearchMode.Regex, memory.LastCriteria.Mode);
+ }
+
+ [TestMethod]
+ public async Task SearchMemory_ModeRegex_LowerCase_PassesRegex()
+ {
+ var memory = new StubLongTermMemory();
+ var tools = MakeTools(memory);
+
+ await tools.SearchMemory("anything", mode: "regex");
+
+ Assert.IsNotNull(memory.LastCriteria);
+ Assert.AreEqual(MemorySearchMode.Regex, memory.LastCriteria.Mode);
+ }
+
+ [TestMethod]
+ public async Task SearchMemory_UnknownMode_ReturnsErrorString_AndDoesNotCallStore()
+ {
+ var memory = new StubLongTermMemory();
+ var tools = MakeTools(memory);
+
+ var result = await tools.SearchMemory("anything", mode: "fuzzy");
+
+ StringAssert.Contains(result, "Unknown search mode");
+ Assert.IsNull(memory.LastCriteria, "Store should not be called for an invalid mode.");
+ }
+
+ [TestMethod]
+ public async Task SearchMemory_MemorySearchException_ReturnsMessage()
+ {
+ var memory = new StubLongTermMemory
+ {
+ SearchOverride = _ => throw new MemorySearchException("bad pattern: nope")
+ };
+ var tools = MakeTools(memory);
+
+ var result = await tools.SearchMemory("[", mode: "regex");
+
+ StringAssert.Contains(result, "bad pattern: nope");
+ }
+
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
@@ -246,6 +312,9 @@ internal sealed class StubLongTermMemory : ILongTermMemory
{
private readonly List _entries = [];
+ public MemorySearchCriteria? LastCriteria { get; private set; }
+ public Func>? SearchOverride { get; set; }
+
public void Add(MemoryEntry entry) => _entries.Add(entry);
public Task SaveAsync(MemoryEntry entry, CancellationToken cancellationToken = default)
@@ -256,8 +325,12 @@ public Task SaveAsync(MemoryEntry entry, CancellationToken cancellationToken = d
}
public Task> SearchAsync(
- MemorySearchCriteria criteria, CancellationToken cancellationToken = default) =>
- Task.FromResult>([.. _entries]);
+ MemorySearchCriteria criteria, CancellationToken cancellationToken = default)
+ {
+ LastCriteria = criteria;
+ var results = SearchOverride is not null ? SearchOverride(criteria) : (IReadOnlyList)[.. _entries];
+ return Task.FromResult(results);
+ }
public Task GetAsync(string id, CancellationToken cancellationToken = default) =>
Task.FromResult(_entries.FirstOrDefault(e => e.Id == id));
diff --git a/tests/RockBot.Host.Tests/FileMemoryStoreTests.cs b/tests/RockBot.Host.Tests/FileMemoryStoreTests.cs
index bcd2960..6c59bf8 100644
--- a/tests/RockBot.Host.Tests/FileMemoryStoreTests.cs
+++ b/tests/RockBot.Host.Tests/FileMemoryStoreTests.cs
@@ -419,6 +419,224 @@ public async Task SearchAsync_WithQuery_TagsContributeToScore()
Assert.IsTrue(results.Any(r => r.Id == "content"));
}
+ // ── Regex mode ────────────────────────────────────────────────────────────
+
+ [TestMethod]
+ public async Task Search_RegexMode_MatchesLiteralToken()
+ {
+ var store = CreateStore();
+ await store.SaveAsync(CreateEntry("v0", "deploy v0.10.30 to staging"));
+ await store.SaveAsync(CreateEntry("other", "unrelated content"));
+
+ var results = await store.SearchAsync(new MemorySearchCriteria(
+ Query: @"v\d+\.\d+\.\d+", Mode: MemorySearchMode.Regex));
+
+ Assert.AreEqual(1, results.Count);
+ Assert.AreEqual("v0", results[0].Id);
+ }
+
+ [TestMethod]
+ public async Task Search_RegexMode_MatchesMemoryIdInPathName()
+ {
+ var store = CreateStore();
+ // Content does not mention "openrouter-key" — only the id does.
+ await store.SaveAsync(CreateEntry("openrouter-key-rotation", "An unrelated note about rotation."));
+ await store.SaveAsync(CreateEntry("foo", "Different entry."));
+
+ var results = await store.SearchAsync(new MemorySearchCriteria(
+ Query: "openrouter-key", Mode: MemorySearchMode.Regex));
+
+ Assert.AreEqual(1, results.Count);
+ Assert.AreEqual("openrouter-key-rotation", results[0].Id);
+ }
+
+ [TestMethod]
+ public async Task Search_RegexMode_MatchesCategoryInPathName()
+ {
+ var store = CreateStore();
+ await store.SaveAsync(CreateEntry("foo", "Some content", category: "project-context"));
+ await store.SaveAsync(CreateEntry("bar", "Other content", category: "user-preferences"));
+
+ var results = await store.SearchAsync(new MemorySearchCriteria(
+ Query: @"^project-context/", Mode: MemorySearchMode.Regex));
+
+ Assert.AreEqual(1, results.Count);
+ Assert.AreEqual("foo", results[0].Id);
+ }
+
+ [TestMethod]
+ public async Task Search_RegexMode_DoesNotMatchOnDiskFilePath()
+ {
+ // The on-disk path ends in ".json" but the regex surface deliberately omits it.
+ // A pattern that hits the storage layout (.json suffix) must not match anything.
+ var store = CreateStore();
+ await store.SaveAsync(CreateEntry("entry-1", "Some plain content"));
+
+ var results = await store.SearchAsync(new MemorySearchCriteria(
+ Query: @"\.json$", Mode: MemorySearchMode.Regex));
+
+ Assert.AreEqual(0, results.Count, "Storage-layout details must not be exposed to regex matches.");
+ }
+
+ [TestMethod]
+ public async Task Search_RegexMode_DefaultIsCaseInsensitive()
+ {
+ var store = CreateStore();
+ await store.SaveAsync(CreateEntry("h", "Helm release in cluster"));
+
+ var results = await store.SearchAsync(new MemorySearchCriteria(
+ Query: "helm", Mode: MemorySearchMode.Regex));
+
+ Assert.AreEqual(1, results.Count);
+ }
+
+ [TestMethod]
+ public async Task Search_RegexMode_CaseSensitive_RespectsFlag()
+ {
+ var store = CreateStore();
+ await store.SaveAsync(CreateEntry("h", "Helm release in cluster"));
+
+ var lowerResults = await store.SearchAsync(new MemorySearchCriteria(
+ Query: "helm", Mode: MemorySearchMode.Regex, RegexCaseSensitive: true));
+ Assert.AreEqual(0, lowerResults.Count);
+
+ var upperResults = await store.SearchAsync(new MemorySearchCriteria(
+ Query: "Helm", Mode: MemorySearchMode.Regex, RegexCaseSensitive: true));
+ Assert.AreEqual(1, upperResults.Count);
+ }
+
+ [TestMethod]
+ public async Task Search_RegexMode_HonorsCategoryFilter()
+ {
+ var store = CreateStore();
+ await store.SaveAsync(CreateEntry("a", "shared keyword", category: "project-context"));
+ await store.SaveAsync(CreateEntry("b", "shared keyword", category: "user-preferences"));
+
+ var results = await store.SearchAsync(new MemorySearchCriteria(
+ Query: "shared", Category: "project-context", Mode: MemorySearchMode.Regex));
+
+ Assert.AreEqual(1, results.Count);
+ Assert.AreEqual("a", results[0].Id);
+ }
+
+ [TestMethod]
+ public async Task Search_RegexMode_RespectsMaxResults()
+ {
+ var store = CreateStore();
+ for (int i = 0; i < 10; i++)
+ await store.SaveAsync(CreateEntry($"e{i}", "matching content"));
+
+ var results = await store.SearchAsync(new MemorySearchCriteria(
+ Query: "matching", Mode: MemorySearchMode.Regex, MaxResults: 3));
+
+ Assert.AreEqual(3, results.Count);
+ }
+
+ [TestMethod]
+ public async Task Search_RegexMode_InvalidPattern_Throws()
+ {
+ var store = CreateStore();
+ await store.SaveAsync(CreateEntry("a", "anything"));
+
+ await Assert.ThrowsExactlyAsync(async () =>
+ {
+ await store.SearchAsync(new MemorySearchCriteria(
+ Query: "[", Mode: MemorySearchMode.Regex));
+ });
+ }
+
+ [TestMethod]
+ public async Task Search_RegexMode_NoMatches_ReturnsEmpty()
+ {
+ var store = CreateStore();
+ await store.SaveAsync(CreateEntry("a", "the cat sat on the mat"));
+
+ var results = await store.SearchAsync(new MemorySearchCriteria(
+ Query: "nothing-here", Mode: MemorySearchMode.Regex));
+
+ Assert.AreEqual(0, results.Count);
+ }
+
+ [TestMethod]
+ public async Task Search_RegexMode_OrdersByImportanceThenLastSeen()
+ {
+ var store = CreateStore();
+ var now = DateTimeOffset.UtcNow;
+
+ await store.SaveAsync(new MemoryEntry("low", "shared content", null, [], now,
+ ImportanceScore: 0.2f) { LastSeenAt = now });
+ await store.SaveAsync(new MemoryEntry("highOld", "shared content", null, [], now.AddDays(-10),
+ ImportanceScore: 0.9f) { LastSeenAt = now.AddDays(-10) });
+ await store.SaveAsync(new MemoryEntry("highRecent", "shared content", null, [], now,
+ ImportanceScore: 0.9f) { LastSeenAt = now });
+
+ var results = await store.SearchAsync(new MemorySearchCriteria(
+ Query: "shared", Mode: MemorySearchMode.Regex));
+
+ Assert.AreEqual(3, results.Count);
+ Assert.AreEqual("highRecent", results[0].Id, "Highest importance, most recent wins.");
+ Assert.AreEqual("highOld", results[1].Id, "Same importance, older LastSeenAt comes second.");
+ Assert.AreEqual("low", results[2].Id);
+ }
+
+ [TestMethod]
+ public async Task Search_RegexMode_NullQuery_FallsBackToNoQueryPath()
+ {
+ var store = CreateStore();
+ var now = DateTimeOffset.UtcNow;
+ await store.SaveAsync(new MemoryEntry("old", "old", null, [], now.AddDays(-5)));
+ await store.SaveAsync(new MemoryEntry("new", "new", null, [], now));
+
+ var results = await store.SearchAsync(new MemorySearchCriteria(
+ Mode: MemorySearchMode.Regex));
+
+ Assert.AreEqual(2, results.Count);
+ Assert.AreEqual("new", results[0].Id, "Null query falls through to LastSeenAt ordering.");
+ }
+
+ [TestMethod]
+ public void Search_RegexMode_PathologicalPattern_ThrowsTimeout()
+ {
+ // Catastrophic backtracking: (a+)+$ on a string that's all 'a's plus a trailing 'b'.
+ // Force a tiny per-entry timeout so the test stays under ~200ms.
+ var entry = new MemoryEntry("evil", new string('a', 60) + "b", null, [], DateTimeOffset.UtcNow);
+
+ Assert.ThrowsExactly(() =>
+ RegexMatcher.MatchEntries(
+ new[] { entry },
+ @"(a+)+$",
+ caseSensitive: false,
+ maxResults: 10,
+ FileMemoryStore.BuildRegexSurface,
+ perEntryTimeout: TimeSpan.FromMilliseconds(50),
+ overallBudget: TimeSpan.FromSeconds(5)));
+ }
+
+ [TestMethod]
+ public void Search_RegexMode_OverallBudget_BoundsTotalScan()
+ {
+ // Build many candidates and force per-entry surface generation to be slow via the
+ // documentText delegate. Each "entry" adds ~5ms to wall clock; with a 30ms overall
+ // budget we expect to throw before scanning all 200.
+ var now = DateTimeOffset.UtcNow;
+ var candidates = Enumerable.Range(0, 200)
+ .Select(i => new MemoryEntry($"e{i}", "content", null, [], now))
+ .ToList();
+
+ var ex = Assert.ThrowsExactly(() =>
+ RegexMatcher.MatchEntries(
+ candidates,
+ "content",
+ caseSensitive: false,
+ maxResults: 10,
+ e => { Thread.Sleep(5); return e.Content; },
+ perEntryTimeout: TimeSpan.FromSeconds(1),
+ overallBudget: TimeSpan.FromMilliseconds(30)));
+
+ StringAssert.Contains(ex.Message, "/200");
+ StringAssert.Contains(ex.Message, "exceeded");
+ }
+
// ── Tokenizer unit tests ──────────────────────────────────────────────────
[TestMethod]