mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:10:49 +00:00
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:
committed by
GitHub
parent
978a50a3c5
commit
2c716f5677
@@ -44,6 +44,7 @@ export default definePluginEntry({
|
||||
createMemorySearchTool({
|
||||
config: ctx.config,
|
||||
agentSessionKey: ctx.sessionKey,
|
||||
sandboxed: ctx.sandboxed,
|
||||
}),
|
||||
{ names: ["memory_search"] },
|
||||
);
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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();
|
||||
|
||||
151
extensions/memory-core/src/session-search-visibility.test.ts
Normal file
151
extensions/memory-core/src/session-search-visibility.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
63
extensions/memory-core/src/session-search-visibility.ts
Normal file
63
extensions/memory-core/src/session-search-visibility.ts
Normal 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;
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user