Files
openclaw/extensions/memory-core/src/tools.ts
Michiel van den Donker 2c716f5677 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>
2026-04-25 09:30:21 +05:30

428 lines
15 KiB
TypeScript

import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
asToolParamsRecord,
jsonResult,
readNumberParam,
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,
} from "openclaw/plugin-sdk/memory-core-host-runtime-files";
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,
decorateCitations,
resolveMemoryCitationsMode,
shouldIncludeCitations,
} from "./tools.citations.js";
import {
buildMemorySearchUnavailableResult,
createMemoryTool,
getMemoryCorpusSupplementResult,
getMemoryManagerContext,
getMemoryManagerContextWithPurpose,
loadMemoryToolRuntime,
MemoryGetSchema,
MemorySearchSchema,
searchMemoryCorpusSupplements,
} from "./tools.shared.js";
function buildRecallKey(
result: Pick<MemorySearchResult, "source" | "path" | "startLine" | "endLine">,
): string {
return `${result.source}:${result.path}:${result.startLine}:${result.endLine}`;
}
function resolveRecallTrackingResults(
rawResults: MemorySearchResult[],
surfacedResults: MemorySearchResult[],
): MemorySearchResult[] {
if (surfacedResults.length === 0 || rawResults.length === 0) {
return surfacedResults;
}
const rawByKey = new Map<string, MemorySearchResult>();
for (const raw of rawResults) {
const key = buildRecallKey(raw);
if (!rawByKey.has(key)) {
rawByKey.set(key, raw);
}
}
return surfacedResults.map((surfaced) => rawByKey.get(buildRecallKey(surfaced)) ?? surfaced);
}
function queueShortTermRecallTracking(params: {
workspaceDir?: string;
query: string;
rawResults: MemorySearchResult[];
surfacedResults: MemorySearchResult[];
timezone?: string;
}): void {
const trackingResults = resolveRecallTrackingResults(params.rawResults, params.surfacedResults);
void recordShortTermRecalls({
workspaceDir: params.workspaceDir,
query: params.query,
results: trackingResults,
timezone: params.timezone,
}).catch(() => {
// Recall tracking is best-effort and must never block memory recall.
});
}
function normalizeActiveMemoryQmdSearchMode(
value: unknown,
): "inherit" | "search" | "vsearch" | "query" {
return value === "inherit" || value === "search" || value === "vsearch" || value === "query"
? value
: "search";
}
function isActiveMemorySessionKey(sessionKey?: string): boolean {
return typeof sessionKey === "string" && sessionKey.includes(":active-memory:");
}
function resolveActiveMemoryQmdSearchModeOverride(
cfg: OpenClawConfig,
sessionKey?: string,
): "search" | "vsearch" | "query" | undefined {
if (!isActiveMemorySessionKey(sessionKey)) {
return undefined;
}
const entry = cfg.plugins?.entries?.["active-memory"];
const entryRecord =
entry && typeof entry === "object" && !Array.isArray(entry)
? (entry as { config?: unknown })
: undefined;
const pluginConfig =
entryRecord?.config &&
typeof entryRecord.config === "object" &&
!Array.isArray(entryRecord.config)
? (entryRecord.config as { qmd?: { searchMode?: unknown } })
: undefined;
const searchMode = normalizeActiveMemoryQmdSearchMode(pluginConfig?.qmd?.searchMode);
return searchMode === "inherit" ? undefined : searchMode;
}
async function getSupplementMemoryReadResult(params: {
relPath: string;
from?: number;
lines?: number;
agentSessionKey?: string;
corpus?: "memory" | "wiki" | "all";
}) {
const supplement = await getMemoryCorpusSupplementResult({
lookup: params.relPath,
fromLine: params.from,
lineCount: params.lines,
agentSessionKey: params.agentSessionKey,
corpus: params.corpus,
});
if (!supplement) {
return null;
}
const { content, ...rest } = supplement;
return {
...rest,
text: content,
};
}
async function resolveMemoryReadFailureResult(params: {
error: unknown;
requestedCorpus?: "memory" | "wiki" | "all";
relPath: string;
from?: number;
lines?: number;
agentSessionKey?: string;
}) {
if (params.requestedCorpus === "all") {
const supplement = await getSupplementMemoryReadResult({
relPath: params.relPath,
from: params.from,
lines: params.lines,
agentSessionKey: params.agentSessionKey,
corpus: params.requestedCorpus,
});
if (supplement) {
return jsonResult(supplement);
}
}
const message = formatErrorMessage(params.error);
return jsonResult({ path: params.relPath, text: "", disabled: true, error: message });
}
async function executeMemoryReadResult<T>(params: {
read: () => Promise<T>;
requestedCorpus?: "memory" | "wiki" | "all";
relPath: string;
from?: number;
lines?: number;
agentSessionKey?: string;
}) {
try {
return jsonResult(await params.read());
} catch (error) {
return await resolveMemoryReadFailureResult({
error,
requestedCorpus: params.requestedCorpus,
relPath: params.relPath,
from: params.from,
lines: params.lines,
agentSessionKey: params.agentSessionKey,
});
}
}
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. `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 }) =>
async (_toolCallId, params) => {
const rawParams = asToolParamsRecord(params);
const query = readStringParam(rawParams, "query", { required: true });
const maxResults = readNumberParam(rawParams, "maxResults");
const minScore = readNumberParam(rawParams, "minScore");
const requestedCorpus = readStringParam(rawParams, "corpus") as
| "memory"
| "wiki"
| "all"
| "sessions"
| undefined;
const { resolveMemoryBackendConfig } = await loadMemoryToolRuntime();
const shouldQueryMemory = requestedCorpus !== "wiki";
const shouldQuerySupplements = requestedCorpus === "wiki" || requestedCorpus === "all";
const memory = shouldQueryMemory ? await getMemoryManagerContext({ cfg, agentId }) : null;
if (shouldQueryMemory && memory && "error" in memory && !shouldQuerySupplements) {
return jsonResult(buildMemorySearchUnavailableResult(memory.error));
}
try {
const citationsMode = resolveMemoryCitationsMode(cfg);
const includeCitations = shouldIncludeCitations({
mode: citationsMode,
sessionKey: options.agentSessionKey,
});
const searchStartedAt = Date.now();
let rawResults: MemorySearchResult[] = [];
let surfacedMemoryResults: Array<
Record<string, unknown> & { corpus: "memory"; score: number; path: string }
> = [];
let provider: string | undefined;
let model: string | undefined;
let fallback: unknown;
let searchMode: string | undefined;
let searchDebug:
| {
backend: string;
configuredMode?: string;
effectiveMode?: string;
fallback?: string;
searchMs: number;
hits: number;
}
| undefined;
if (shouldQueryMemory && memory && !("error" in memory)) {
const runtimeDebug: MemorySearchRuntimeDebug[] = [];
const qmdSearchModeOverride = resolveActiveMemoryQmdSearchModeOverride(
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,
sessionKey: options.agentSessionKey,
qmdSearchModeOverride,
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 });
const memoryResults =
status.backend === "qmd"
? clampResultsByInjectedChars(decorated, resolved.qmd?.limits.maxInjectedChars)
: decorated;
surfacedMemoryResults = memoryResults.map((result) => ({
...result,
corpus: "memory" as const,
}));
const sleepTimezone = resolveMemoryDeepDreamingConfig({
pluginConfig: resolveMemoryCorePluginConfig(cfg),
cfg,
}).timezone;
queueShortTermRecallTracking({
workspaceDir: status.workspaceDir,
query,
rawResults,
surfacedResults: memoryResults,
timezone: sleepTimezone,
});
provider = status.provider;
model = status.model;
fallback = status.fallback;
const latestDebug = runtimeDebug.at(-1);
searchMode = latestDebug?.effectiveMode;
searchDebug = {
backend: status.backend,
configuredMode: latestDebug?.configuredMode,
effectiveMode:
status.backend === "qmd"
? (latestDebug?.effectiveMode ?? latestDebug?.configuredMode)
: "n/a",
fallback: latestDebug?.fallback,
searchMs: Math.max(0, Date.now() - searchStartedAt),
hits: rawResults.length,
};
}
const supplementResults = shouldQuerySupplements
? await searchMemoryCorpusSupplements({
query,
maxResults,
agentSessionKey: options.agentSessionKey,
corpus: requestedCorpus,
})
: [];
const results = [...surfacedMemoryResults, ...supplementResults]
.toSorted((left, right) => {
if (left.score !== right.score) {
return right.score - left.score;
}
return left.path.localeCompare(right.path);
})
.slice(0, Math.max(1, maxResults ?? 10));
return jsonResult({
results,
provider,
model,
fallback,
citations: citationsMode,
mode: searchMode,
debug: searchDebug,
});
} catch (err) {
const message = formatErrorMessage(err);
return jsonResult(buildMemorySearchUnavailableResult(message));
}
},
});
}
export function createMemoryGetTool(options: {
config?: OpenClawConfig;
agentSessionKey?: string;
}) {
return createMemoryTool({
options,
label: "Memory Get",
name: "memory_get",
description:
"Safe exact excerpt read from MEMORY.md or memory/*.md. Defaults to a bounded excerpt when lines are omitted, includes truncation/continuation info when more content exists, and `corpus=wiki` reads from registered compiled-wiki supplements.",
parameters: MemoryGetSchema,
execute:
({ cfg, agentId }) =>
async (_toolCallId, params) => {
const rawParams = asToolParamsRecord(params);
const relPath = readStringParam(rawParams, "path", { required: true });
const from = readNumberParam(rawParams, "from", { integer: true });
const lines = readNumberParam(rawParams, "lines", { integer: true });
const requestedCorpus = readStringParam(rawParams, "corpus") as
| "memory"
| "wiki"
| "all"
| undefined;
const { readAgentMemoryFile, resolveMemoryBackendConfig } = await loadMemoryToolRuntime();
if (requestedCorpus === "wiki") {
const supplement = await getSupplementMemoryReadResult({
relPath,
from: from ?? undefined,
lines: lines ?? undefined,
agentSessionKey: options.agentSessionKey,
corpus: requestedCorpus,
});
return jsonResult(
supplement ?? {
path: relPath,
text: "",
disabled: true,
error: "wiki corpus result not found",
},
);
}
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
if (resolved.backend === "builtin") {
return await executeMemoryReadResult({
read: async () =>
await readAgentMemoryFile({
cfg,
agentId,
relPath,
from: from ?? undefined,
lines: lines ?? undefined,
}),
requestedCorpus,
relPath,
from: from ?? undefined,
lines: lines ?? undefined,
agentSessionKey: options.agentSessionKey,
});
}
const memory = await getMemoryManagerContextWithPurpose({
cfg,
agentId,
purpose: "status",
});
if ("error" in memory) {
return jsonResult({ path: relPath, text: "", disabled: true, error: memory.error });
}
return await executeMemoryReadResult({
read: async () =>
await memory.manager.readFile({
relPath,
from: from ?? undefined,
lines: lines ?? undefined,
}),
requestedCorpus,
relPath,
from: from ?? undefined,
lines: lines ?? undefined,
agentSessionKey: options.agentSessionKey,
});
},
});
}