Skip to content
Open
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
53 changes: 47 additions & 6 deletions src/tools/memory-recall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ const MAX_EXPAND_TOKENS = 8000;
const MAX_EXPAND_CHARS = MAX_EXPAND_TOKENS * 4;
const MAX_GREP_RESULTS = 50;
const MAX_GREP_CHARS = 40000;
const MAX_GREP_MATCH_CHARS = 8000;
const MAX_GREP_REGEX_CHARS = 512;
const MAX_SNIPPET_CHARS = 200;

// ── Schemas ──
Expand Down Expand Up @@ -174,15 +176,53 @@ function formatEvictionCueLine(cue: string | undefined, summaryId: string): stri
return `[Summary ${summaryId}]: ${firstLine}`;
}

function safeMatch(text: string, pattern: string, mode: "regex" | "text"): boolean {
if (mode === "text") return text.toLowerCase().includes(pattern.toLowerCase());
function stripRegexClassesAndEscapes(pattern: string): string {
let result = "";
let escaped = false;
let inClass = false;
for (const ch of pattern) {
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
if (inClass) {
if (ch === "]") inClass = false;
continue;
}
if (ch === "[") {
inClass = true;
continue;
}
result += ch;
}
return result;
}

function compileSafeGrepRegex(pattern: string): RegExp | undefined {
if (pattern.length > MAX_GREP_REGEX_CHARS) {
throw new Error(`memory_grep regex patterns are limited to ${MAX_GREP_REGEX_CHARS} characters`);
}
const structural = stripRegexClassesAndEscapes(pattern);
if (/\((?:[^()]|\\.)*[+*{](?:[^()]|\\.)*\)\s*[+*{]/u.test(structural)) {
throw new Error("memory_grep regex pattern is unsafe: nested quantified groups are not allowed");
}
try {
return new RegExp(pattern, "i").test(text);
return new RegExp(pattern, "i");
} catch {
return text.toLowerCase().includes(pattern.toLowerCase());
return undefined;
}
}

function safeMatch(text: string, pattern: string, mode: "regex" | "text", regex?: RegExp): boolean {
if (mode === "text") return text.toLowerCase().includes(pattern.toLowerCase());
if (!regex) return text.toLowerCase().includes(pattern.toLowerCase());
return regex.test(text.slice(0, MAX_GREP_MATCH_CHARS));
}

// ── Tool factories ──

export function createMemoryDescribeTool(
Expand Down Expand Up @@ -394,6 +434,7 @@ export function createMemoryGrepTool(
if (!pattern) throw new Error("memory_grep requires pattern");

const mode = (params.mode === "regex" ? "regex" : "text") as "regex" | "text";
const regex = mode === "regex" ? compileSafeGrepRegex(pattern) : undefined;
const scope = (params.scope === "messages" ? "messages" : params.scope === "summaries" ? "summaries" : "both") as "messages" | "summaries" | "both";
const limit = readNum(params, "limit", { integer: true }) ?? MAX_GREP_RESULTS;
const sessionId = readStr(params, "sessionId") ?? getSessionId() ?? "";
Expand All @@ -414,7 +455,7 @@ export function createMemoryGrepTool(
});
for (const r of (summaryResults.results ?? [])) {
if (summaries.length >= limit || totalChars >= MAX_GREP_CHARS) break;
if (!safeMatch(r.text, pattern, mode)) continue;
if (!safeMatch(r.text, pattern, mode, regex)) continue;
totalMatches++;
let evictionCue: string | undefined;
if (r.metadataJson && r.metadataJson.length > 0) {
Expand All @@ -439,7 +480,7 @@ export function createMemoryGrepTool(
});
for (const r of (turnResults.results ?? [])) {
if (turns.length >= limit || totalChars >= MAX_GREP_CHARS) break;
if (!safeMatch(r.text, pattern, mode)) continue;
if (!safeMatch(r.text, pattern, mode, regex)) continue;
totalMatches++;
const snippet = truncateSnippet(r.text);
let role = "unknown";
Expand Down
6 changes: 0 additions & 6 deletions test/integration/markdown-ingest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,6 @@ import path from "node:path";

import { createMarkdownIngestionHandle, type FsDirentLike } from "../../src/markdown-ingest.js";

type FsDirentLike = {
name: string;
isDirectory(): boolean;
isFile(): boolean;
};

class FakeRpcClient {
calls: Array<{ method: string; params: unknown }> = [];
documents = new Map<string, { text: string; tokenizerId: string; coreDoc: boolean; sourceMeta: Record<string, unknown> }>();
Expand Down
29 changes: 29 additions & 0 deletions test/unit/memory-recall.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,35 @@ test("memory_grep defaults to the active session id", async () => {
assert.equal(client.calls[0]?.params.collection, "session_summary:active-session");
});

test("memory_grep regex mode matches safe regex patterns", async () => {
const client = new FakeRecallClient();
const tool = createMemoryGrepTool(
async () => client as unknown as LibravDBClient,
() => "active-session",
silentLogger,
);

const result = await tool.execute("call-1", { pattern: "need(le|les)", mode: "regex", scope: "summaries" });

assert.equal((result.details as { totalMatches: number }).totalMatches, 1);
assert.equal(client.calls[0]?.method, "searchText");
});

test("memory_grep rejects nested quantified regex before searching", async () => {
const client = new FakeRecallClient();
const tool = createMemoryGrepTool(
async () => client as unknown as LibravDBClient,
() => "active-session",
silentLogger,
);

await assert.rejects(
() => tool.execute("call-1", { pattern: "(a+)+$", mode: "regex", scope: "summaries" }),
/nested quantified groups/u,
);
assert.equal(client.calls.length, 0);
});

test("memory_expand defaults to the active session id", async () => {
const client = new FakeRecallClient();
const tool = createMemoryExpandTool(
Expand Down