Files
openclaw/extensions/memory-core/src/tools.shared.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

194 lines
5.3 KiB
TypeScript

import {
listMemoryCorpusSupplements,
resolveMemorySearchConfig,
resolveSessionAgentId,
type MemoryCorpusSearchResult,
type AnyAgentTool,
type OpenClawConfig,
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { Type } from "typebox";
type MemoryToolRuntime = typeof import("./tools.runtime.js");
type MemorySearchManagerResult = Awaited<
ReturnType<(typeof import("./memory/index.js"))["getMemorySearchManager"]>
>;
let memoryToolRuntimePromise: Promise<MemoryToolRuntime> | null = null;
export async function loadMemoryToolRuntime(): Promise<MemoryToolRuntime> {
memoryToolRuntimePromise ??= import("./tools.runtime.js");
return await memoryToolRuntimePromise;
}
export const MemorySearchSchema = Type.Object({
query: Type.String(),
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.Literal("sessions"),
]),
),
});
export const MemoryGetSchema = Type.Object({
path: Type.String(),
from: Type.Optional(Type.Number()),
lines: Type.Optional(Type.Number()),
corpus: Type.Optional(
Type.Union([Type.Literal("memory"), Type.Literal("wiki"), Type.Literal("all")]),
),
});
export function resolveMemoryToolContext(options: {
config?: OpenClawConfig;
agentSessionKey?: string;
}) {
const cfg = options.config;
if (!cfg) {
return null;
}
const agentId = resolveSessionAgentId({
sessionKey: options.agentSessionKey,
config: cfg,
});
if (!resolveMemorySearchConfig(cfg, agentId)) {
return null;
}
return { cfg, agentId };
}
export async function getMemoryManagerContext(params: {
cfg: OpenClawConfig;
agentId: string;
}): Promise<
| {
manager: NonNullable<MemorySearchManagerResult["manager"]>;
}
| {
error: string | undefined;
}
> {
return await getMemoryManagerContextWithPurpose({ ...params, purpose: undefined });
}
export async function getMemoryManagerContextWithPurpose(params: {
cfg: OpenClawConfig;
agentId: string;
purpose?: "default" | "status";
}): Promise<
| {
manager: NonNullable<MemorySearchManagerResult["manager"]>;
}
| {
error: string | undefined;
}
> {
const { getMemorySearchManager } = await loadMemoryToolRuntime();
const { manager, error } = await getMemorySearchManager({
cfg: params.cfg,
agentId: params.agentId,
purpose: params.purpose,
});
return manager ? { manager } : { error };
}
export function createMemoryTool(params: {
options: {
config?: OpenClawConfig;
agentSessionKey?: string;
};
label: string;
name: string;
description: string;
parameters: typeof MemorySearchSchema | typeof MemoryGetSchema;
execute: (ctx: { cfg: OpenClawConfig; agentId: string }) => AnyAgentTool["execute"];
}): AnyAgentTool | null {
const ctx = resolveMemoryToolContext(params.options);
if (!ctx) {
return null;
}
return {
label: params.label,
name: params.name,
description: params.description,
parameters: params.parameters,
execute: params.execute(ctx),
};
}
export function buildMemorySearchUnavailableResult(error: string | undefined) {
const reason = (error ?? "memory search unavailable").trim() || "memory search unavailable";
const isQuotaError = /insufficient_quota|quota|429/.test(normalizeLowercaseStringOrEmpty(reason));
const warning = isQuotaError
? "Memory search is unavailable because the embedding provider quota is exhausted."
: "Memory search is unavailable due to an embedding/provider error.";
const action = isQuotaError
? "Top up or switch embedding provider, then retry memory_search."
: "Check embedding provider configuration and retry memory_search.";
return {
results: [],
disabled: true,
unavailable: true,
error: reason,
warning,
action,
debug: {
warning,
action,
error: reason,
},
};
}
export async function searchMemoryCorpusSupplements(params: {
query: string;
maxResults?: number;
agentSessionKey?: string;
corpus?: "memory" | "wiki" | "all" | "sessions";
}): Promise<MemoryCorpusSearchResult[]> {
if (params.corpus === "memory" || params.corpus === "sessions") {
return [];
}
const supplements = listMemoryCorpusSupplements();
if (supplements.length === 0) {
return [];
}
const results = (
await Promise.all(
supplements.map(async (registration) => await registration.supplement.search(params)),
)
).flat();
return results
.toSorted((left, right) => {
if (left.score !== right.score) {
return right.score - left.score;
}
return left.path.localeCompare(right.path);
})
.slice(0, Math.max(1, params.maxResults ?? 10));
}
export async function getMemoryCorpusSupplementResult(params: {
lookup: string;
fromLine?: number;
lineCount?: number;
agentSessionKey?: string;
corpus?: "memory" | "wiki" | "all" | "sessions";
}) {
if (params.corpus === "memory" || params.corpus === "sessions") {
return null;
}
for (const registration of listMemoryCorpusSupplements()) {
const result = await registration.supplement.get(params);
if (result) {
return result;
}
}
return null;
}