fix: enforce memory search session visibility (#70761) (thanks @nefainl)

* [EV-001] memory-core: filter memory_search session hits by visibility

- Move session visibility + listSpawnedSessionKeys to plugin-sdk; sync test
  hook with sessions-resolution __testing.setDepsForTest
- Extract loadCombinedSessionStoreForGateway to config/sessions; re-export
  from gateway session-utils
- Add session-transcript-hit stem resolver for builtin + QMD paths
- Post-filter memory_search results before citations/recall; fail closed when
  requester session key missing; optional corpus=sessions
- Tests: stem extraction, visibility filter smoke, existing suites green

* chore: sync plugin-sdk exports for session-transcript-hit and session-visibility

Run pnpm plugin-sdk:sync-exports so package.json exports match
scripts/lib/plugin-sdk-entrypoints.json. Fixes contract tests and
lint:plugins:plugin-sdk-subpaths-exported for memory-core imports.

* fix(EV-001): cross-agent session memory hits + hoist combined store load

- resolveTranscriptStemToSessionKeys: stop filtering by requester agentId so
  keys from other agents reach createSessionVisibilityGuard (a2a + visibility=all).
- Re-export loadCombinedSessionStoreForGateway from session-transcript-hit;
  filterMemorySearchHitsBySessionVisibility loads the combined store once per pass.
- Drop unused agentId from filter params; extend tests (Greptile/Codex review).

* fix(memory_search): honor corpus=sessions before maxResults cap

Pass sources into MemoryIndexManager.search so FTS/vector queries add
source IN (...) before ranking and top-N slice (Codex: non-session hits
could fill the window).

QMD path: oversample fetch limit for single-source recall, filter by
source, then diversify/clamp to the requested maxResults.

Wire corpus=sessions from tools; extend MemorySearchManager opts and
wrappers.

* fix(memory_search): apply corpus=memory source filter like sessions

Pass sources: ["memory"] into manager.search so maxResults applies only
within the memory index; post-filter for defense in depth. Document
corpus=memory in the tool description.

* fix: scope qmd session memory search

* fix: enforce memory search session visibility (#70761) (thanks @nefainl)

---------

Co-authored-by: NefAI <info@nefai.nl>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Michiel van den Donker
2026-04-25 06:00:21 +02:00
committed by GitHub
parent 978a50a3c5
commit 2c716f5677
23 changed files with 901 additions and 350 deletions

View File

@@ -44,6 +44,7 @@ export default definePluginEntry({
createMemorySearchTool({
config: ctx.config,
agentSessionKey: ctx.sessionKey,
sandboxed: ctx.sandboxed,
}),
{ names: ["memory_search"] },
);

View File

@@ -280,8 +280,11 @@ export abstract class MemoryManagerSyncOps {
}
}
protected buildSourceFilter(alias?: string): { sql: string; params: MemorySource[] } {
const sources = Array.from(this.sources);
protected buildSourceFilter(
alias?: string,
sourcesOverride?: MemorySource[],
): { sql: string; params: MemorySource[] } {
const sources = sourcesOverride ?? Array.from(this.sources);
if (sources.length === 0) {
return { sql: "", params: [] };
}

View File

@@ -294,6 +294,8 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
sessionKey?: string;
qmdSearchModeOverride?: "query" | "search" | "vsearch";
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
/** When set, only these chunk sources are considered (must be enabled for this manager). */
sources?: MemorySource[];
},
): Promise<MemorySearchResult[]> {
opts?.onDebug?.({ backend: "builtin" });
@@ -332,6 +334,14 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
}
const minScore = opts?.minScore ?? this.settings.query.minScore;
const maxResults = opts?.maxResults ?? this.settings.query.maxResults;
const searchSources =
opts?.sources && opts.sources.length > 0
? [...new Set(opts.sources)].filter((s) => this.sources.has(s))
: undefined;
if (opts?.sources && opts.sources.length > 0 && (!searchSources || searchSources.length === 0)) {
return [];
}
const sourceFilterList = searchSources ?? [...this.sources];
const hybrid = this.settings.query.hybrid;
const candidates = Math.min(
200,
@@ -345,9 +355,14 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
return [];
}
const fullQueryResults = await this.searchKeyword(cleaned, candidates, {
boostFallbackRanking: true,
}).catch(() => []);
const fullQueryResults = await this.searchKeyword(
cleaned,
candidates,
{
boostFallbackRanking: true,
},
sourceFilterList,
).catch(() => []);
const resultSets =
fullQueryResults.length > 0
? [fullQueryResults]
@@ -360,7 +375,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
});
const searchTerms = keywords.length > 0 ? keywords : [cleaned];
return searchTerms.map((term) =>
this.searchKeyword(term, candidates, { boostFallbackRanking: true }).catch(
this.searchKeyword(term, candidates, { boostFallbackRanking: true }, sourceFilterList).catch(
() => [],
),
);
@@ -391,13 +406,13 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
// If FTS isn't available, hybrid mode cannot use keyword search; degrade to vector-only.
const keywordResults =
hybrid.enabled && this.fts.enabled && this.fts.available
? await this.searchKeyword(cleaned, candidates).catch(() => [])
? await this.searchKeyword(cleaned, candidates, undefined, sourceFilterList).catch(() => [])
: [];
const queryVec = await this.embedQueryWithTimeout(cleaned);
const hasVector = queryVec.some((v) => v !== 0);
const vectorResults = hasVector
? await this.searchVector(queryVec, candidates).catch(() => [])
? await this.searchVector(queryVec, candidates, sourceFilterList).catch(() => [])
: [];
if (!hybrid.enabled || !this.fts.enabled || !this.fts.available) {
@@ -473,6 +488,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
private async searchVector(
queryVec: number[],
limit: number,
sourceFilterList: MemorySource[],
): Promise<Array<MemorySearchResult & { id: string }>> {
// This method should never be called without a provider
if (!this.provider) {
@@ -486,8 +502,8 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
limit,
snippetMaxChars: SNIPPET_MAX_CHARS,
ensureVectorReady: async (dimensions) => await this.ensureVectorReady(dimensions),
sourceFilterVec: this.buildSourceFilter("c"),
sourceFilterChunks: this.buildSourceFilter(),
sourceFilterVec: this.buildSourceFilter("c", sourceFilterList),
sourceFilterChunks: this.buildSourceFilter(undefined, sourceFilterList),
});
return results.map((entry) => entry as MemorySearchResult & { id: string });
}
@@ -500,11 +516,12 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
query: string,
limit: number,
options?: { boostFallbackRanking?: boolean },
sourceFilterList?: MemorySource[],
): Promise<Array<MemorySearchResult & { id: string; textScore: number }>> {
if (!this.fts.enabled || !this.fts.available) {
return [];
}
const sourceFilter = this.buildSourceFilter();
const sourceFilter = this.buildSourceFilter(undefined, sourceFilterList);
// In FTS-only mode (no provider), search all models; otherwise filter by current provider's model
const providerModel = this.provider?.model;
const results = await searchKeyword({

View File

@@ -4329,6 +4329,82 @@ describe("QmdMemoryManager", () => {
await manager.close();
});
it("restricts qmd search to session collections before result limiting", async () => {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
sessions: { enabled: true },
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "search" && args.includes("workspace-main")) {
const child = createMockChild({ autoClose: false });
emitAndClose(
child,
"stdout",
JSON.stringify([
{
file: "qmd://workspace-main/notes.md",
score: 0.99,
snippet: "@@ -1,1\nmemory hit",
},
]),
);
return child;
}
if (args[0] === "search" && args.includes("sessions-main")) {
const child = createMockChild({ autoClose: false });
emitAndClose(
child,
"stdout",
JSON.stringify([
{
file: "qmd://sessions-main/session-1.md",
score: 0.8,
snippet: "@@ -2,1\nsession hit",
},
]),
);
return child;
}
return createMockChild();
});
const { manager } = await createManager({ mode: "full" });
const results = await manager.search("hit", {
sessionKey: "agent:main:slack:dm:u123",
sources: ["sessions"],
maxResults: 1,
});
expect(results).toEqual([
{
path: "qmd/sessions-main/session-1.md",
startLine: 2,
endLine: 2,
score: 0.8,
snippet: "@@ -2,1\nsession hit",
source: "sessions",
},
]);
const searchCalls = spawnMock.mock.calls
.map((call: unknown[]) => call[1] as string[])
.filter((args) => args[0] === "search");
expect(searchCalls).toHaveLength(1);
expect(searchCalls[0]).toContain("sessions-main");
expect(searchCalls[0]).not.toContain("workspace-main");
await manager.close();
});
it("preserves multi-collection qmd search hits when results only include file URIs", async () => {
cfg = {
...cfg,

View File

@@ -1058,6 +1058,7 @@ export class QmdMemoryManager implements MemorySearchManager {
sessionKey?: string;
qmdSearchModeOverride?: "query" | "search" | "vsearch";
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
sources?: MemorySource[];
},
): Promise<MemorySearchResult[]> {
if (!this.isScopeAllowed(opts?.sessionKey)) {
@@ -1071,11 +1072,13 @@ export class QmdMemoryManager implements MemorySearchManager {
await this.maybeWarmSession(opts?.sessionKey);
await this.maybeSyncDirtySearchState();
await this.waitForPendingUpdateBeforeSearch();
const limit = Math.min(
const resultLimit = Math.min(
this.qmd.limits.maxResults,
opts?.maxResults ?? this.qmd.limits.maxResults,
);
const collectionNames = this.listManagedCollectionNames();
const requestedSources = opts?.sources?.length ? [...new Set(opts.sources)] : undefined;
const collectionNames = this.listManagedCollectionNames(requestedSources);
const limit = resultLimit;
if (collectionNames.length === 0) {
log.warn("qmd query skipped: no managed collections configured");
return [];
@@ -1149,8 +1152,6 @@ export class QmdMemoryManager implements MemorySearchManager {
}
const args = this.buildSearchArgs(qmdSearchCommand, trimmed, limit);
args.push(...this.buildCollectionFilterArgs(collectionNames));
// Always scope to managed collections (default + custom). Even for `search`/`vsearch`,
// pass collection filters; if a given QMD build rejects these flags, we fall back to `query`.
const result = await this.runQmd(args, { timeoutMs: this.qmd.limits.timeoutMs });
return parseQmdQueryJson(result.stdout, result.stderr);
} catch (err) {
@@ -1229,7 +1230,12 @@ export class QmdMemoryManager implements MemorySearchManager {
effectiveMode: effectiveSearchMode,
fallback: searchFallbackReason,
});
return this.clampResultsByInjectedChars(this.diversifyResultsBySource(results, limit));
let ranked = results;
if (opts?.sources?.length) {
const allow = new Set(opts.sources);
ranked = results.filter((r) => allow.has(r.source));
}
return this.clampResultsByInjectedChars(this.diversifyResultsBySource(ranked, resultLimit));
}
async sync(params?: {
@@ -2974,8 +2980,15 @@ export class QmdMemoryManager implements MemorySearchManager {
return [...bestByDocId.values()].toSorted((a, b) => (b.score ?? 0) - (a.score ?? 0));
}
private listManagedCollectionNames(): string[] {
return this.managedCollectionNames;
private listManagedCollectionNames(sources?: MemorySource[]): string[] {
if (!sources?.length) {
return this.managedCollectionNames;
}
const allowed = new Set(sources);
return this.managedCollectionNames.filter((name) => {
const source = this.collectionRoots.get(name)?.kind;
return source ? allowed.has(source) : false;
});
}
private computeManagedCollectionNames(): string[] {

View File

@@ -14,6 +14,7 @@ import {
type MemoryEmbeddingProbeResult,
type MemorySearchManager,
type MemorySearchRuntimeDebug,
type MemorySource,
type MemorySyncProgressUpdate,
type ResolvedQmdConfig,
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
@@ -258,6 +259,7 @@ class BorrowedMemoryManager implements MemorySearchManager {
sessionKey?: string;
qmdSearchModeOverride?: "query" | "search" | "vsearch";
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
sources?: MemorySource[];
},
) {
return await this.inner.search(query, opts);
@@ -334,6 +336,7 @@ class FallbackMemoryManager implements MemorySearchManager {
sessionKey?: string;
qmdSearchModeOverride?: "query" | "search" | "vsearch";
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
sources?: MemorySource[];
},
) {
this.ensureOpen();

View File

@@ -0,0 +1,151 @@
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import * as sessionTranscriptHit from "openclaw/plugin-sdk/session-transcript-hit";
import { afterEach, describe, expect, it, vi } from "vitest";
import { filterMemorySearchHitsBySessionVisibility } from "./session-search-visibility.js";
import { asOpenClawConfig } from "./tools.test-helpers.js";
const crossAgentStore = {
"agent:peer:only": {
sessionId: "w1",
updatedAt: 1,
sessionFile: "/tmp/sessions/w1.jsonl",
},
};
vi.mock("openclaw/plugin-sdk/session-transcript-hit", async (importOriginal) => {
const actual =
await importOriginal<typeof import("openclaw/plugin-sdk/session-transcript-hit")>();
return {
...actual,
loadCombinedSessionStoreForGateway: vi.fn(() => ({
storePath: "(test)",
store: crossAgentStore,
})),
};
});
describe("filterMemorySearchHitsBySessionVisibility", () => {
afterEach(() => {
vi.mocked(sessionTranscriptHit.loadCombinedSessionStoreForGateway).mockClear();
});
it("drops sessions-sourced hits when requester key is missing (fail closed)", async () => {
const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "all" } } });
const hits: MemorySearchResult[] = [
{
path: "sessions/u1.jsonl",
source: "sessions",
score: 1,
snippet: "x",
startLine: 1,
endLine: 2,
},
];
const filtered = await filterMemorySearchHitsBySessionVisibility({
cfg,
requesterSessionKey: undefined,
sandboxed: false,
hits,
});
expect(filtered).toEqual([]);
});
it("keeps non-session hits unchanged", async () => {
const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "all" } } });
const hits: MemorySearchResult[] = [
{
path: "memory/foo.md",
source: "memory",
score: 1,
snippet: "x",
startLine: 1,
endLine: 2,
},
];
const filtered = await filterMemorySearchHitsBySessionVisibility({
cfg,
requesterSessionKey: "agent:main:main",
sandboxed: false,
hits,
});
expect(filtered).toEqual(hits);
});
it("loads the combined session store once per filter pass", async () => {
const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "all" } } });
const hits: MemorySearchResult[] = [
{
path: "sessions/w1.jsonl",
source: "sessions",
score: 1,
snippet: "a",
startLine: 1,
endLine: 2,
},
{
path: "sessions/w1.jsonl",
source: "sessions",
score: 0.9,
snippet: "b",
startLine: 1,
endLine: 2,
},
];
await filterMemorySearchHitsBySessionVisibility({
cfg,
requesterSessionKey: "agent:main:main",
sandboxed: false,
hits,
});
expect(sessionTranscriptHit.loadCombinedSessionStoreForGateway).toHaveBeenCalledTimes(1);
expect(sessionTranscriptHit.loadCombinedSessionStoreForGateway).toHaveBeenCalledWith(cfg);
});
it("allows cross-agent session hits when visibility=all and agent-to-agent is enabled", async () => {
const hit: MemorySearchResult = {
path: "sessions/w1.jsonl",
source: "sessions",
score: 1,
snippet: "x",
startLine: 1,
endLine: 2,
};
const cfg = asOpenClawConfig({
tools: {
sessions: { visibility: "all" },
agentToAgent: { enabled: true, allow: ["*"] },
},
});
const filtered = await filterMemorySearchHitsBySessionVisibility({
cfg,
requesterSessionKey: "agent:main:main",
sandboxed: false,
hits: [hit],
});
expect(filtered).toEqual([hit]);
});
it("denies cross-agent session hits when agent-to-agent is disabled", async () => {
const hit: MemorySearchResult = {
path: "sessions/w1.jsonl",
source: "sessions",
score: 1,
snippet: "x",
startLine: 1,
endLine: 2,
};
const cfg = asOpenClawConfig({
tools: {
sessions: { visibility: "all" },
agentToAgent: { enabled: false },
},
});
const filtered = await filterMemorySearchHitsBySessionVisibility({
cfg,
requesterSessionKey: "agent:main:main",
sandboxed: false,
hits: [hit],
});
expect(filtered).toEqual([]);
});
});

View File

@@ -0,0 +1,63 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import {
extractTranscriptStemFromSessionsMemoryHit,
loadCombinedSessionStoreForGateway,
resolveTranscriptStemToSessionKeys,
} from "openclaw/plugin-sdk/session-transcript-hit";
import {
createAgentToAgentPolicy,
createSessionVisibilityGuard,
resolveEffectiveSessionToolsVisibility,
} from "openclaw/plugin-sdk/session-visibility";
export async function filterMemorySearchHitsBySessionVisibility(params: {
cfg: OpenClawConfig;
requesterSessionKey: string | undefined;
sandboxed: boolean;
hits: MemorySearchResult[];
}): Promise<MemorySearchResult[]> {
const visibility = resolveEffectiveSessionToolsVisibility({
cfg: params.cfg,
sandboxed: params.sandboxed,
});
const a2aPolicy = createAgentToAgentPolicy(params.cfg);
const guard = params.requesterSessionKey
? await createSessionVisibilityGuard({
action: "history",
requesterSessionKey: params.requesterSessionKey,
visibility,
a2aPolicy,
})
: null;
const { store: combinedSessionStore } = loadCombinedSessionStoreForGateway(params.cfg);
const next: MemorySearchResult[] = [];
for (const hit of params.hits) {
if (hit.source !== "sessions") {
next.push(hit);
continue;
}
if (!params.requesterSessionKey || !guard) {
continue;
}
const stem = extractTranscriptStemFromSessionsMemoryHit(hit.path);
if (!stem) {
continue;
}
const keys = resolveTranscriptStemToSessionKeys({
store: combinedSessionStore,
stem,
});
if (keys.length === 0) {
continue;
}
const allowed = keys.some((key) => guard.check(key).allowed);
if (!allowed) {
continue;
}
next.push(hit);
}
return next;
}

View File

@@ -2,7 +2,6 @@ import {
listMemoryCorpusSupplements,
resolveMemorySearchConfig,
resolveSessionAgentId,
type MemoryCorpusGetResult,
type MemoryCorpusSearchResult,
type AnyAgentTool,
type OpenClawConfig,
@@ -27,7 +26,12 @@ export const MemorySearchSchema = Type.Object({
maxResults: Type.Optional(Type.Number()),
minScore: Type.Optional(Type.Number()),
corpus: Type.Optional(
Type.Union([Type.Literal("memory"), Type.Literal("wiki"), Type.Literal("all")]),
Type.Union([
Type.Literal("memory"),
Type.Literal("wiki"),
Type.Literal("all"),
Type.Literal("sessions"),
]),
),
});
@@ -145,9 +149,9 @@ export async function searchMemoryCorpusSupplements(params: {
query: string;
maxResults?: number;
agentSessionKey?: string;
corpus?: "memory" | "wiki" | "all";
corpus?: "memory" | "wiki" | "all" | "sessions";
}): Promise<MemoryCorpusSearchResult[]> {
if (params.corpus === "memory") {
if (params.corpus === "memory" || params.corpus === "sessions") {
return [];
}
const supplements = listMemoryCorpusSupplements();
@@ -174,9 +178,9 @@ export async function getMemoryCorpusSupplementResult(params: {
fromLine?: number;
lineCount?: number;
agentSessionKey?: string;
corpus?: "memory" | "wiki" | "all";
}): Promise<MemoryCorpusGetResult | null> {
if (params.corpus === "memory") {
corpus?: "memory" | "wiki" | "all" | "sessions";
}) {
if (params.corpus === "memory" || params.corpus === "sessions") {
return null;
}
for (const registration of listMemoryCorpusSupplements()) {

View File

@@ -6,6 +6,7 @@ import {
readStringParam,
type OpenClawConfig,
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import type { MemorySource } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import type {
MemorySearchResult,
MemorySearchRuntimeDebug,
@@ -14,6 +15,7 @@ import {
resolveMemoryCorePluginConfig,
resolveMemoryDeepDreamingConfig,
} from "openclaw/plugin-sdk/memory-core-host-status";
import { filterMemorySearchHitsBySessionVisibility } from "./session-search-visibility.js";
import { recordShortTermRecalls } from "./short-term-promotion.js";
import {
clampResultsByInjectedChars,
@@ -181,13 +183,14 @@ async function executeMemoryReadResult<T>(params: {
export function createMemorySearchTool(options: {
config?: OpenClawConfig;
agentSessionKey?: string;
sandboxed?: boolean;
}) {
return createMemoryTool({
options,
label: "Memory Search",
name: "memory_search",
description:
"Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts) before answering questions about prior work, decisions, dates, people, preferences, or todos. Optional `corpus=wiki` or `corpus=all` also searches registered compiled-wiki supplements. If response has disabled=true, memory retrieval is unavailable and should be surfaced to the user.",
"Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts) before answering questions about prior work, decisions, dates, people, preferences, or todos. Optional `corpus=wiki` or `corpus=all` also searches registered compiled-wiki supplements. `corpus=memory` restricts hits to indexed memory files (excludes session transcript chunks from ranking). `corpus=sessions` restricts hits to indexed session transcripts (same visibility rules as session history tools). If response has disabled=true, memory retrieval is unavailable and should be surfaced to the user.",
parameters: MemorySearchSchema,
execute:
({ cfg, agentId }) =>
@@ -200,6 +203,7 @@ export function createMemorySearchTool(options: {
| "memory"
| "wiki"
| "all"
| "sessions"
| undefined;
const { resolveMemoryBackendConfig } = await loadMemoryToolRuntime();
const shouldQueryMemory = requestedCorpus !== "wiki";
@@ -239,6 +243,12 @@ export function createMemorySearchTool(options: {
cfg,
options.agentSessionKey,
);
const searchSources: MemorySource[] | undefined =
requestedCorpus === "sessions"
? (["sessions"] as MemorySource[])
: requestedCorpus === "memory"
? (["memory"] as MemorySource[])
: undefined;
rawResults = await memory.manager.search(query, {
maxResults,
minScore,
@@ -247,7 +257,19 @@ export function createMemorySearchTool(options: {
onDebug: (debug) => {
runtimeDebug.push(debug);
},
...(searchSources ? { sources: searchSources } : {}),
});
rawResults = await filterMemorySearchHitsBySessionVisibility({
cfg,
requesterSessionKey: options.agentSessionKey,
sandboxed: options.sandboxed === true,
hits: rawResults,
});
if (requestedCorpus === "sessions") {
rawResults = rawResults.filter((hit) => hit.source === "sessions");
} else if (requestedCorpus === "memory") {
rawResults = rawResults.filter((hit) => hit.source === "memory");
}
const status = memory.manager.status();
const decorated = decorateCitations(rawResults, includeCitations);
const resolved = resolveMemoryBackendConfig({ cfg, agentId });