fix(memory): recall prior conversation after session reset (#122051)

* fix(memory): recall archived session generations after reset

* test(memory): prove private recall across reset

* fix(memory): keep deleted transcripts outside reset recall

* fix(memory): reject deleted archives from reset recall

* fix(memory): isolate recall across sqlite resets

* test(memory): split reset recall coverage

* fix(memory): keep reset recall metadata private

* fix(memory): keep reset authority scoped

---------

Co-authored-by: TheAngryPit <16145902+TheAngryPit@users.noreply.github.com>
This commit is contained in:
Vitor Cepeda Lopes
2026-08-12 20:38:13 +01:00
committed by GitHub
parent 11fe5abaa9
commit a9ee6618fe
15 changed files with 1267 additions and 57 deletions
@@ -17,6 +17,8 @@ import {
hashText, hashText,
INVALID_PROJECT_ANNOTATION_KEY, INVALID_PROJECT_ANNOTATION_KEY,
MEMORY_EMBEDDING_CACHE_TABLE, MEMORY_EMBEDDING_CACHE_TABLE,
MEMORY_INDEX_CHUNK_PROVENANCE_TABLE,
MEMORY_INDEX_CHUNK_RECALL_METADATA_TABLE,
MEMORY_INDEX_FTS_TABLE, MEMORY_INDEX_FTS_TABLE,
MEMORY_INDEX_VECTOR_TABLE, MEMORY_INDEX_VECTOR_TABLE,
remapChunkLines, remapChunkLines,
@@ -24,14 +26,13 @@ import {
runWithConcurrency, runWithConcurrency,
stripMemoryAnnotationCarriers, stripMemoryAnnotationCarriers,
type MemoryChunk, type MemoryChunk,
type MemorySource,
type MemoryEntryProvenance, type MemoryEntryProvenance,
MEMORY_INDEX_CHUNK_PROVENANCE_TABLE, type MemorySource,
MEMORY_INDEX_CHUNK_RECALL_METADATA_TABLE,
} from "openclaw/plugin-sdk/memory-core-host-engine-storage"; } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import { MAX_TIMER_TIMEOUT_MS, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; import { MAX_TIMER_TIMEOUT_MS, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
import { runSqliteImmediateTransactionSync } from "openclaw/plugin-sdk/sqlite-runtime"; import { runSqliteImmediateTransactionSync } from "openclaw/plugin-sdk/sqlite-runtime";
import { readSessionResetRecallCutoffMetadata } from "../session-reset-recall-metadata.js";
import type { EmbeddingProvider } from "./embeddings.js"; import type { EmbeddingProvider } from "./embeddings.js";
import { import {
MEMORY_BATCH_FAILURE_LIMIT, MEMORY_BATCH_FAILURE_LIMIT,
@@ -59,6 +60,7 @@ import {
resolveMemoryIndexProviderIdentities, resolveMemoryIndexProviderIdentities,
type MemoryIndexProviderIdentity, type MemoryIndexProviderIdentity,
} from "./manager-reindex-state.js"; } from "./manager-reindex-state.js";
import { chunkSessionContentAtResetBoundary } from "./manager-reset-chunk-boundary.js";
import { import {
MemoryManagerSyncOps, MemoryManagerSyncOps,
type MemoryIndexWorkItem, type MemoryIndexWorkItem,
@@ -1121,11 +1123,19 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps {
(normalizedEntryPath === "MEMORY.md" || normalizedEntryPath === "USER.md"); (normalizedEntryPath === "MEMORY.md" || normalizedEntryPath === "USER.md");
const indexingContent = const indexingContent =
options.source === "memory" ? stripMemoryAnnotationCarriers(content) : content; options.source === "memory" ? stripMemoryAnnotationCarriers(content) : content;
const chunkOptions = { ...this.settings.chunking, perEntry };
const baseChunks = filterNonEmptyMemoryChunks( const baseChunks = filterNonEmptyMemoryChunks(
chunkMarkdown(indexingContent, { options.source === "sessions"
...this.settings.chunking, ? chunkSessionContentAtResetBoundary({
perEntry, content: indexingContent,
}), cutoffLine: (() => {
const cutoff = readSessionResetRecallCutoffMetadata(entry);
return cutoff.state === "valid" ? cutoff.cutoffLine : undefined;
})(),
lineMap: entry.lineMap,
chunking: chunkOptions,
})
: chunkMarkdown(indexingContent, chunkOptions),
); );
for (const chunk of baseChunks) { for (const chunk of baseChunks) {
chunk.provenance = this.resolveChunkProvenance( chunk.provenance = this.resolveChunkProvenance(
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { chunkSessionContentAtResetBoundary } from "./manager-reset-chunk-boundary.js";
describe("chunkSessionContentAtResetBoundary", () => {
it("never overlaps a pre-reset chunk into the current generation", () => {
const chunks = chunkSessionContentAtResetBoundary({
content: "old one\nold two\nkept live\ncurrent turn",
cutoffLine: 7,
lineMap: [2, 4, 7, 9],
chunking: { tokens: 100, overlap: 50 },
});
expect(chunks.map((chunk) => [chunk.startLine, chunk.endLine, chunk.text])).toEqual([
[1, 2, "old one\nold two"],
[3, 4, "kept live\ncurrent turn"],
]);
});
});
@@ -0,0 +1,32 @@
import {
chunkMarkdown,
type MemoryChunk,
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
export function chunkSessionContentAtResetBoundary(params: {
content: string;
cutoffLine?: number;
lineMap?: readonly number[];
chunking: { tokens: number; overlap: number; perEntry?: boolean };
}): MemoryChunk[] {
const cutoffIndex =
params.cutoffLine !== undefined && params.lineMap
? params.lineMap.findIndex((line) => line >= params.cutoffLine!)
: -1;
if (cutoffIndex <= 0) {
return chunkMarkdown(params.content, params.chunking);
}
const lines = params.content.split("\n");
const chunkPartition = (content: string, lineOffset: number) => {
const chunks = chunkMarkdown(content, params.chunking);
for (const chunk of chunks) {
chunk.startLine += lineOffset;
chunk.endLine += lineOffset;
}
return chunks;
};
return [
...chunkPartition(lines.slice(0, cutoffIndex).join("\n"), 0),
...chunkPartition(lines.slice(cutoffIndex).join("\n"), cutoffIndex),
];
}
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { readSessionArchiveReasonFromHitPath } from "./session-reset-recall-metadata.js";
describe("readSessionArchiveReasonFromHitPath", () => {
it.each([
["sessions/a.jsonl.reset.2026-08-11T08-00-00Z", "reset"],
["sessions/a.jsonl.reset.2026-08-11T08-00-00.000Z.zst", "reset"],
["sessions/a.jsonl.deleted.2026-08-11T08-00-00Z", "deleted"],
["sessions\\a.jsonl.deleted.2026-08-11T08-00-00.000Z.zst", "deleted"],
["sessions/a.jsonl.reset.2026-08-11T08:00:00Z", undefined],
["sessions/a.jsonl.reset.2026-08-11T08-00-00Z.gz", undefined],
["sessions/a.jsonl.deleted.2026-08-11T08-00-00Z.extra", undefined],
["sessions/a.jsonl.RESET.2026-08-11T08-00-00Z", undefined],
])("classifies %s", (path, expected) => {
expect(readSessionArchiveReasonFromHitPath(path)).toBe(expected);
});
});
@@ -0,0 +1,35 @@
export type SessionResetRecallCutoff =
| { state: "absent" }
| { state: "invalid" }
| { cutoffLine: number; state: "valid" };
const RESET_RECALL_CUTOFF = Symbol.for("openclaw.memory.sessionResetRecallCutoff");
export function readSessionResetRecallCutoffMetadata(value: unknown): SessionResetRecallCutoff {
if (!value || typeof value !== "object") {
return { state: "invalid" };
}
const cutoff = (value as Record<PropertyKey, unknown>)[RESET_RECALL_CUTOFF];
if (!cutoff || typeof cutoff !== "object") {
return { state: "invalid" };
}
const state = (cutoff as { state?: unknown }).state;
if (state === "absent" || state === "invalid") {
return { state };
}
const cutoffLine = (cutoff as { cutoffLine?: unknown }).cutoffLine;
return state === "valid" && typeof cutoffLine === "number" && Number.isInteger(cutoffLine)
? { state, cutoffLine }
: { state: "invalid" };
}
export function readSessionArchiveReasonFromHitPath(
hitPath: string,
): "reset" | "deleted" | undefined {
const match =
/\.jsonl\.(reset|deleted)\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(?:\.\d{3})?Z(?:\.zst)?$/.exec(
hitPath,
);
const reason = match?.[1];
return reason === "reset" || reason === "deleted" ? reason : undefined;
}
@@ -0,0 +1,255 @@
import * as engineSessions from "openclaw/plugin-sdk/memory-core-host-engine-sessions";
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";
type TestSessionEntry = {
sessionId: string;
updatedAt: number;
sessionFile: string;
chatType?: "direct" | "group" | "channel";
};
let combinedSessionStore: Record<string, TestSessionEntry> = {};
function entryWithCutoff(cutoff: unknown) {
const entry = {};
Object.defineProperty(entry, Symbol.for("openclaw.memory.sessionResetRecallCutoff"), {
enumerable: false,
value: cutoff,
});
return entry;
}
vi.mock("openclaw/plugin-sdk/memory-core-host-engine-sessions", async (importOriginal) => {
const actual =
await importOriginal<typeof import("openclaw/plugin-sdk/memory-core-host-engine-sessions")>();
return {
...actual,
buildSessionEntry: vi.fn(async () => entryWithCutoff({ state: "absent" })),
};
});
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: combinedSessionStore,
})),
};
});
describe("reset-generation session search visibility", () => {
afterEach(() => {
vi.mocked(sessionTranscriptHit.loadCombinedSessionStoreForGateway).mockClear();
vi.mocked(engineSessions.buildSessionEntry).mockReset();
vi.mocked(engineSessions.buildSessionEntry).mockResolvedValue(
entryWithCutoff({ state: "absent" }) as never,
);
combinedSessionStore = {};
});
it.each([
{ name: "pre-reset", range: [2, 3], cutoff: { state: "valid", cutoffLine: 4 }, kept: true },
{ name: "crossing", range: [3, 4], cutoff: { state: "valid", cutoffLine: 4 }, kept: false },
{ name: "current", range: [4, 5], cutoff: { state: "valid", cutoffLine: 4 }, kept: false },
{ name: "missing", range: [1, 2], cutoff: { state: "absent" }, kept: false },
{ name: "missing-contract", range: [1, 2], cutoff: undefined, kept: false },
{ name: "malformed", range: [1, 2], cutoff: { state: "invalid" }, kept: false },
] as const)(
"handles a $name live SQLite reset-generation hit",
async ({ range, cutoff, kept }) => {
const anchorSessionKey = "agent:main:telegram:direct:owner";
combinedSessionStore = {
[anchorSessionKey]: {
sessionId: "current",
updatedAt: 2,
sessionFile: "/tmp/sessions/current.jsonl",
chatType: "direct",
},
};
vi.mocked(engineSessions.buildSessionEntry).mockResolvedValue(
(cutoff === undefined ? {} : entryWithCutoff(cutoff)) as never,
);
const hit: MemorySearchResult = {
path: "sessions/main/current.jsonl",
source: "sessions",
score: 1,
snippet: "short fact",
startLine: range[0],
endLine: range[1],
};
const filtered = await filterMemorySearchHitsBySessionVisibility({
cfg: asOpenClawConfig({ tools: { sessions: { visibility: "self" } } }),
agentId: "main",
requesterSessionKey: `${anchorSessionKey}:active-memory:123456abcdef`,
sandboxed: false,
hits: [hit],
conversationRecall: { anchorSessionKey, scope: "same-agent-private", corpus: "sessions" },
});
expect(filtered).toEqual(kept ? [hit] : []);
},
);
it("resolves the live anchor reset cutoff once per filter pass", async () => {
const anchorSessionKey = "agent:main:telegram:direct:owner";
combinedSessionStore = {
[anchorSessionKey]: {
sessionId: "current",
updatedAt: 2,
sessionFile: "/tmp/sessions/current.jsonl",
chatType: "direct",
},
};
vi.mocked(engineSessions.buildSessionEntry).mockResolvedValue(
entryWithCutoff({ state: "valid", cutoffLine: 5 }) as never,
);
const hits: MemorySearchResult[] = [
{
path: "sessions/main/current.jsonl",
source: "sessions",
score: 1,
snippet: "first pre-reset chunk",
startLine: 1,
endLine: 2,
},
{
path: "sessions/main/current.jsonl",
source: "sessions",
score: 0.9,
snippet: "second pre-reset chunk",
startLine: 3,
endLine: 4,
},
];
const filtered = await filterMemorySearchHitsBySessionVisibility({
cfg: asOpenClawConfig({ tools: { sessions: { visibility: "self" } } }),
agentId: "main",
requesterSessionKey: `${anchorSessionKey}:active-memory:123456abcdef`,
sandboxed: false,
hits,
conversationRecall: { anchorSessionKey, scope: "same-agent-private", corpus: "sessions" },
});
expect(filtered).toEqual(hits);
expect(engineSessions.buildSessionEntry).toHaveBeenCalledTimes(1);
expect(engineSessions.buildSessionEntry).toHaveBeenCalledWith("current.jsonl", {
agentId: "main",
sessionId: "current",
sessionKey: anchorSessionKey,
storePath: "(test)",
updatedAtMs: 2,
});
});
it.each(["", ".zst"])(
"allows an archived reset generation of the private anchor conversation%s",
async (compressionSuffix) => {
const anchorSessionKey = "agent:main:telegram:direct:owner";
combinedSessionStore = {
[anchorSessionKey]: {
sessionId: "current",
updatedAt: 2,
sessionFile: "/tmp/sessions/current.jsonl",
chatType: "direct",
},
};
const hit: MemorySearchResult = {
path: `sessions/main/current.jsonl.reset.2026-08-11T08-00-00.000Z${compressionSuffix}`,
source: "sessions",
score: 1,
snippet: "prior conversation context",
startLine: 1,
endLine: 2,
};
const filtered = await filterMemorySearchHitsBySessionVisibility({
cfg: asOpenClawConfig({ tools: { sessions: { visibility: "self" } } }),
agentId: "main",
requesterSessionKey: `${anchorSessionKey}:active-memory:123456abcdef`,
sandboxed: false,
hits: [hit],
conversationRecall: { anchorSessionKey, scope: "same-agent-private", corpus: "sessions" },
});
expect(filtered).toEqual([hit]);
},
);
it.each([
{
name: "the private anchor conversation",
path: "sessions/main/current.jsonl.deleted.2026-08-11T08-00-00.000Z",
snippet: "explicitly deleted private context",
includeDeletedSource: false,
},
{
name: "the compressed private anchor conversation",
path: "sessions/main/current.jsonl.deleted.2026-08-11T08-00-00.000Z.zst",
snippet: "explicitly deleted compressed private context",
includeDeletedSource: false,
},
{
name: "another private conversation",
path: "sessions/main/deleted-source.jsonl.deleted.2026-08-11T08-00-00.000Z",
snippet: "intentionally deleted private context",
includeDeletedSource: true,
},
{
name: "another compressed private conversation",
path: "sessions/main/deleted-source.jsonl.deleted.2026-08-11T08-00-00.000Z.zst",
snippet: "intentionally deleted compressed private context",
includeDeletedSource: true,
},
])(
"denies an archived deleted generation from $name",
async ({ path, snippet, includeDeletedSource }) => {
const anchorSessionKey = "agent:main:telegram:direct:owner";
combinedSessionStore = {
[anchorSessionKey]: {
sessionId: "current",
updatedAt: 2,
sessionFile: "/tmp/sessions/current.jsonl",
chatType: "direct",
},
...(includeDeletedSource
? {
"agent:main:telegram:direct:deleted-source": {
sessionId: "deleted-source",
updatedAt: 1,
sessionFile: "/tmp/sessions/deleted-source.jsonl",
chatType: "direct" as const,
},
}
: {}),
};
const hit: MemorySearchResult = {
path,
source: "sessions",
score: 1,
snippet,
startLine: 1,
endLine: 2,
};
const filtered = await filterMemorySearchHitsBySessionVisibility({
cfg: asOpenClawConfig({ tools: { sessions: { visibility: "self" } } }),
agentId: "main",
requesterSessionKey: `${anchorSessionKey}:active-memory:123456abcdef`,
sandboxed: false,
hits: [hit],
conversationRecall: { anchorSessionKey, scope: "same-agent-private", corpus: "sessions" },
});
expect(filtered).toEqual([]);
},
);
});
@@ -282,10 +282,10 @@ describe("filterMemorySearchHitsBySessionVisibility", () => {
}, },
}; };
const hit: MemorySearchResult = { const hit: MemorySearchResult = {
path: "sessions/other-private.jsonl", path: "sessions/main/current.jsonl.reset.2026-08-11T08-00-00.000Z",
source: "sessions", source: "sessions",
score: 1, score: 1,
snippet: "private context", snippet: "prior private context",
startLine: 1, startLine: 1,
endLine: 2, endLine: 2,
}; };
@@ -431,45 +431,54 @@ describe("filterMemorySearchHitsBySessionVisibility", () => {
expect(filtered).toStrictEqual([]); expect(filtered).toStrictEqual([]);
}); });
it("denies another agent's private transcript during trusted conversation recall", async () => { it.each([
combinedSessionStore = { { name: "live", path: "sessions/peer-private.jsonl" },
"agent:main:telegram:direct:owner": { {
sessionId: "current", name: "archived",
updatedAt: 2, path: "sessions/peer/peer-private.jsonl.reset.2026-08-11T08-00-00.000Z",
sessionFile: "/tmp/sessions/current.jsonl", },
chatType: "direct", ])(
}, "denies another agent's $name private transcript during trusted conversation recall",
"agent:peer:telegram:direct:owner": { async ({ path }) => {
sessionId: "peer-private", combinedSessionStore = {
updatedAt: 1, "agent:main:telegram:direct:owner": {
sessionFile: "/tmp/sessions/peer-private.jsonl", sessionId: "current",
chatType: "direct", updatedAt: 2,
}, sessionFile: "/tmp/sessions/current.jsonl",
}; chatType: "direct",
const hit: MemorySearchResult = { },
path: "sessions/peer-private.jsonl", "agent:peer:telegram:direct:owner": {
source: "sessions", sessionId: "peer-private",
score: 1, updatedAt: 1,
snippet: "other agent context", sessionFile: "/tmp/sessions/peer-private.jsonl",
startLine: 1, chatType: "direct",
endLine: 2, },
}; };
const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "all" } } }); const hit: MemorySearchResult = {
path,
source: "sessions",
score: 1,
snippet: "other agent context",
startLine: 1,
endLine: 2,
};
const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "all" } } });
const filtered = await filterMemorySearchHitsBySessionVisibility({ const filtered = await filterMemorySearchHitsBySessionVisibility({
cfg, cfg,
requesterSessionKey: "agent:main:telegram:direct:owner", requesterSessionKey: "agent:main:telegram:direct:owner",
sandboxed: false, sandboxed: false,
hits: [hit], hits: [hit],
conversationRecall: { conversationRecall: {
anchorSessionKey: "agent:main:telegram:direct:owner", anchorSessionKey: "agent:main:telegram:direct:owner",
scope: "same-agent-private", scope: "same-agent-private",
corpus: "sessions", corpus: "sessions",
}, },
}); });
expect(filtered).toStrictEqual([]); expect(filtered).toStrictEqual([]);
}); },
);
it("denies persisted Active Memory helper transcripts under explicit sessions", async () => { it("denies persisted Active Memory helper transcripts under explicit sessions", async () => {
combinedSessionStore = { combinedSessionStore = {
@@ -1,4 +1,5 @@
// Memory Core plugin module implements session search visibility behavior. // Memory Core plugin module implements session search visibility behavior.
import { buildSessionEntry } from "openclaw/plugin-sdk/memory-core-host-engine-sessions";
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core"; 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 type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import { resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core"; import { resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core";
@@ -14,6 +15,11 @@ import {
createSessionVisibilityGuard, createSessionVisibilityGuard,
resolveEffectiveSessionToolsVisibility, resolveEffectiveSessionToolsVisibility,
} from "openclaw/plugin-sdk/session-visibility"; } from "openclaw/plugin-sdk/session-visibility";
import {
readSessionArchiveReasonFromHitPath,
readSessionResetRecallCutoffMetadata,
type SessionResetRecallCutoff,
} from "./session-reset-recall-metadata.js";
function normalizeAgentIdForCompare(value: string | undefined): string | undefined { function normalizeAgentIdForCompare(value: string | undefined): string | undefined {
return value?.trim().toLowerCase() || undefined; return value?.trim().toLowerCase() || undefined;
@@ -186,7 +192,7 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
}) })
: null; : null;
const { store: combinedSessionStore } = loadCombinedSessionStoreForGateway( const { store: combinedSessionStore, storePath } = loadCombinedSessionStoreForGateway(
params.cfg, params.cfg,
scopedAgentId ? { agentId: scopedAgentId } : {}, scopedAgentId ? { agentId: scopedAgentId } : {},
); );
@@ -200,6 +206,26 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
? resolveSessionAgentId({ sessionKey: anchorSessionKey, config: params.cfg }) ? resolveSessionAgentId({ sessionKey: anchorSessionKey, config: params.cfg })
: undefined; : undefined;
const anchorEntry = anchorSessionKey ? combinedSessionStore[anchorSessionKey] : undefined; const anchorEntry = anchorSessionKey ? combinedSessionStore[anchorSessionKey] : undefined;
let anchorResetCutoffPromise: Promise<SessionResetRecallCutoff> | undefined;
const resolveAnchorResetCutoff = () => {
if (anchorResetCutoffPromise) {
return anchorResetCutoffPromise;
}
const sessionId = anchorEntry?.sessionId?.trim();
if (!recallAgentId || !sessionId || !anchorSessionKey) {
return Promise.resolve<SessionResetRecallCutoff>({ state: "invalid" });
}
anchorResetCutoffPromise = buildSessionEntry(`${sessionId}.jsonl`, {
agentId: recallAgentId,
sessionId,
sessionKey: anchorSessionKey,
storePath,
updatedAtMs: anchorEntry?.updatedAt,
})
.then(readSessionResetRecallCutoffMetadata)
.catch(() => ({ state: "invalid" }));
return anchorResetCutoffPromise;
};
const recallAuthorized = Boolean( const recallAuthorized = Boolean(
conversationRecall && conversationRecall &&
!params.sandboxed && !params.sandboxed &&
@@ -230,7 +256,7 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
: []; : [];
} }
const isSessionKeyAllowed = (key: string): boolean => { const isSessionKeyAllowed = (key: string, allowAnchorTranscript = false): boolean => {
if (!conversationRecall || !anchorSessionKey || !recallAgentId) { if (!conversationRecall || !anchorSessionKey || !recallAgentId) {
// A bare global key is local to the selected agent store. Reattach that // A bare global key is local to the selected agent store. Reattach that
// owner before applying visibility or non-default agents look cross-agent. // owner before applying visibility or non-default agents look cross-agent.
@@ -242,8 +268,11 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
} }
const candidateEntry = combinedSessionStore[key]; const candidateEntry = combinedSessionStore[key];
// Canonical and legacy alias keys can identify one transcript. Exclude the // Canonical and legacy alias keys can identify one transcript. Exclude the
// anchor by transcript identity so an alias cannot re-inject current context. // live anchor, but let prior archived generations pass the privacy checks below.
if (key === anchorSessionKey || isSameStoredTranscript(anchorEntry, candidateEntry)) { if (
!allowAnchorTranscript &&
(key === anchorSessionKey || isSameStoredTranscript(anchorEntry, candidateEntry))
) {
return false; return false;
} }
const candidateAgentId = resolveSessionAgentId({ sessionKey: key, config: params.cfg }); const candidateAgentId = resolveSessionAgentId({ sessionKey: key, config: params.cfg });
@@ -277,12 +306,12 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
return [...expanded]; return [...expanded];
}; };
const areSessionKeysAllowed = (keys: string[]): boolean => { const areSessionKeysAllowed = (keys: string[], allowAnchorTranscript = false): boolean => {
// Product recall fails closed when aliases disagree about privacy. Ordinary // Product recall fails closed when aliases disagree about privacy. Ordinary
// session-tool visibility keeps its existing any-visible-alias behavior. // session-tool visibility keeps its existing any-visible-alias behavior.
return conversationRecall return conversationRecall
? expandRecallAliasKeys(keys).every(isSessionKeyAllowed) ? expandRecallAliasKeys(keys).every((key) => isSessionKeyAllowed(key, allowAnchorTranscript))
: keys.some(isSessionKeyAllowed); : keys.some((key) => isSessionKeyAllowed(key));
}; };
const next: MemorySearchResult[] = []; const next: MemorySearchResult[] = [];
@@ -300,6 +329,10 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
if (!identity) { if (!identity) {
continue; continue;
} }
const archiveReason = readSessionArchiveReasonFromHitPath(hit.path);
if (conversationRecall && archiveReason === "deleted") {
continue;
}
const normalizedScopedAgentId = normalizeAgentIdForCompare(scopedAgentId); const normalizedScopedAgentId = normalizeAgentIdForCompare(scopedAgentId);
const normalizedOwnerAgentId = normalizeAgentIdForCompare(identity.ownerAgentId); const normalizedOwnerAgentId = normalizeAgentIdForCompare(identity.ownerAgentId);
if ( if (
@@ -342,7 +375,20 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
} }
continue; continue;
} }
const allowed = areSessionKeysAllowed(keys); let allowResetAnchor = false;
const anchorSessionId = anchorEntry?.sessionId?.trim();
if (
conversationRecall &&
!identity.archived &&
recallAgentId &&
anchorSessionId &&
identity.stem === anchorSessionId &&
normalizedOwnerAgentId === normalizeAgentIdForCompare(recallAgentId)
) {
const cutoff = await resolveAnchorResetCutoff();
allowResetAnchor = cutoff?.state === "valid" && hit.endLine < cutoff.cutoffLine;
}
const allowed = areSessionKeysAllowed(keys, archiveReason === "reset" || allowResetAnchor);
if (!allowed) { if (!allowed) {
continue; continue;
} }
@@ -246,6 +246,7 @@ describe("qa scenario catalog", () => {
"matrix-restart-resume", "matrix-restart-resume",
"qa-channel-reconnect-dedupe", "qa-channel-reconnect-dedupe",
"remember-across-conversations", "remember-across-conversations",
"remember-across-reset-private",
"slack-restart-resume", "slack-restart-resume",
"subagent-stale-child-links", "subagent-stale-child-links",
"telegram-repeated-command-authorization", "telegram-repeated-command-authorization",
@@ -2,6 +2,7 @@
import path from "node:path"; import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises"; import { setTimeout as sleep } from "node:timers/promises";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { buildSessionEntry } from "openclaw/plugin-sdk/memory-core-host-engine-sessions";
import { import {
listSessionEntries, listSessionEntries,
loadTranscriptEventsSync, loadTranscriptEventsSync,
@@ -44,6 +45,7 @@ type QaSessionTranscriptSeedParams = {
const SESSION_STORE_FTS_SETTLE_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; const SESSION_STORE_FTS_SETTLE_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const;
const MAX_COMPACTION_SUMMARIES = 16; const MAX_COMPACTION_SUMMARIES = 16;
const MAX_SUCCESSFUL_TOOL_CALL_EVENTS = 64; const MAX_SUCCESSFUL_TOOL_CALL_EVENTS = 64;
const SESSION_RESET_RECALL_CUTOFF = Symbol.for("openclaw.memory.sessionResetRecallCutoff");
type QaSessionTranscriptSummary = { type QaSessionTranscriptSummary = {
assistantMirrors?: Array<{ identity: string; text: string }>; assistantMirrors?: Array<{ identity: string; text: string }>;
@@ -61,11 +63,14 @@ type QaSessionTranscriptSummary = {
lastAssistantStopReason?: string; lastAssistantStopReason?: string;
lastAssistantToolNames?: string[]; lastAssistantToolNames?: string[];
lastMessageRole?: string; lastMessageRole?: string;
resetRecallCutoffLine?: number;
probeTextEndLine?: number;
}; };
type QaSessionTranscriptSummaryOptions = { type QaSessionTranscriptSummaryOptions = {
afterEventCursor?: number; afterEventCursor?: number;
allowEmpty?: boolean; allowEmpty?: boolean;
probeText?: string;
}; };
function isSessionStoreFtsSettleRace(error: unknown) { function isSessionStoreFtsSettleRace(error: unknown) {
@@ -427,7 +432,34 @@ async function readSessionTranscriptSummary(
if (selectedEvents.length === 0 && options.allowEmpty === true) { if (selectedEvents.length === 0 && options.allowEmpty === true) {
return emptySessionTranscriptSummary(events.length); return emptySessionTranscriptSummary(events.length);
} }
return summarizeSessionTranscriptEvents(selectedEvents, normalizedSessionKey, events.length); const summary = summarizeSessionTranscriptEvents(
selectedEvents,
normalizedSessionKey,
events.length,
);
const probeText = options.probeText?.trim();
let cutoff: unknown;
if (probeText) {
const runtimeEnv = qaSessionRuntimeEnv(env.gateway.tempRoot);
const storePath = resolveStorePath(undefined, { agentId: "qa", env: runtimeEnv });
const transcriptEntry = await buildSessionEntry(
path.join(env.gateway.tempRoot, "state", "agents", "qa", "sessions", `${sessionId}.jsonl`),
{ agentId: "qa", sessionId, sessionKey: normalizedSessionKey, storePath },
);
cutoff = transcriptEntry
? (transcriptEntry as unknown as Record<PropertyKey, unknown>)[SESSION_RESET_RECALL_CUTOFF]
: undefined;
}
const probeTextEndLine = probeText
? events.findLastIndex((event) => JSON.stringify(event).includes(probeText)) + 1
: 0;
return {
...summary,
...(isRecord(cutoff) && cutoff.state === "valid" && typeof cutoff.cutoffLine === "number"
? { resetRecallCutoffLine: cutoff.cutoffLine }
: {}),
...(probeTextEndLine > 0 ? { probeTextEndLine } : {}),
};
} }
export { export {
@@ -0,0 +1,107 @@
import fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
import {
clearConfigCache,
clearRuntimeConfigSnapshot,
} from "openclaw/plugin-sdk/runtime-config-snapshot";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
persistSessionTranscriptTurn,
resetSessionEntryLifecycle,
upsertSessionEntryCore,
} from "../../../../src/config/sessions/session-accessor.js";
import { closeOpenClawAgentDatabasesForTest } from "../../../../src/state/openclaw-agent-db.js";
import { closeOpenClawStateDatabaseForTest } from "../../../../src/state/openclaw-state-db.js";
import { buildSessionEntry, type SessionFileEntry } from "./session-files.js";
function requireSessionEntry(entry: SessionFileEntry | null): SessionFileEntry {
if (!entry) {
throw new Error("expected session entry");
}
return entry;
}
let tmpDir: string;
let previousStateDir: string | undefined;
let previousConfigPath: string | undefined;
beforeEach(() => {
tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "session-reset-revision-test-"));
previousStateDir = process.env.OPENCLAW_STATE_DIR;
previousConfigPath = process.env.OPENCLAW_CONFIG_PATH;
Reflect.set(process.env, "OPENCLAW_STATE_DIR", tmpDir);
clearRuntimeConfigSnapshot();
clearConfigCache();
});
afterEach(() => {
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
if (previousStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", previousStateDir);
}
if (previousConfigPath === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_CONFIG_PATH");
} else {
Reflect.set(process.env, "OPENCLAW_CONFIG_PATH", previousConfigPath);
}
clearRuntimeConfigSnapshot();
clearConfigCache();
fsSync.rmSync(tmpDir, { recursive: true, force: true });
});
describe("SQLite session reset content revision", () => {
it("invalidates a session hash when a reset boundary changes its generation", async () => {
const sessionsDir = path.join(tmpDir, "agents", "main", "sessions");
const storePath = path.join(sessionsDir, "sessions.json");
const sessionKey = "agent:main:chat:reset-revision";
const sessionId = "reset-revision";
fsSync.mkdirSync(sessionsDir, { recursive: true });
await upsertSessionEntryCore(
{ agentId: "main", sessionKey, storePath },
{ sessionId, updatedAt: 1 },
);
await persistSessionTranscriptTurn(
{ agentId: "main", sessionId, sessionKey, storePath },
{
messages: [{ message: { role: "user", content: "unchanged exported text" } }],
touchSessionEntry: true,
updateMode: "none",
},
);
const buildOptions = {
agentId: "main",
sessionId,
sessionKey,
storePath,
updatedAtMs: 1,
};
const before = requireSessionEntry(await buildSessionEntry(sessionKey, buildOptions));
await resetSessionEntryLifecycle({
agentId: "main",
buildNextEntry: ({ currentEntry }) => ({
...currentEntry,
sessionId,
updatedAt: 2,
}),
resetBoundaryReason: "reset",
storePath,
target: { canonicalKey: sessionKey, storeKeys: [sessionKey] },
});
const after = requireSessionEntry(await buildSessionEntry(sessionKey, buildOptions));
expect(after.content).toBe(before.content);
expect(after.lineMap).toEqual(before.lineMap);
const cutoffSymbol = Symbol.for("openclaw.memory.sessionResetRecallCutoff");
expect(Object.getOwnPropertyDescriptor(after, cutoffSymbol)).toMatchObject({
enumerable: false,
value: { state: "valid", cutoffLine: expect.any(Number) },
});
expect(Object.keys(after)).not.toContain(cutoffSymbol.description);
expect(after.hash).not.toBe(before.hash);
});
});
@@ -31,6 +31,7 @@ import {
stripInternalRuntimeContext, stripInternalRuntimeContext,
} from "./openclaw-runtime-session.js"; } from "./openclaw-runtime-session.js";
import { retryTransientMemoryRead } from "./read-retry.js"; import { retryTransientMemoryRead } from "./read-retry.js";
import { resolveSessionResetRecallCutoff } from "./session-reset-recall.js";
import { import {
listSessionTranscriptCorpusEntriesForAgent, listSessionTranscriptCorpusEntriesForAgent,
listSessionTranscriptCorpusEntriesForAgentSync, listSessionTranscriptCorpusEntriesForAgentSync,
@@ -775,11 +776,13 @@ export async function buildSessionEntry(
const records = loadTranscriptEventsSync({ const records = loadTranscriptEventsSync({
...sqliteIdentity, ...sqliteIdentity,
}); });
const resetRecallCutoff = resolveSessionResetRecallCutoff(records);
const raw = serializeTranscriptEvents(records); const raw = serializeTranscriptEvents(records);
return { return {
mtimeMs: opts.updatedAtMs ?? stats.maxSeq, mtimeMs: opts.updatedAtMs ?? stats.maxSeq,
path: sessionPathForSessionIdentity(sqliteIdentity.agentId, sqliteIdentity.sessionId), path: sessionPathForSessionIdentity(sqliteIdentity.agentId, sqliteIdentity.sessionId),
raw, raw,
resetRecallCutoff,
size: stats.sizeBytes, size: stats.sizeBytes,
}; };
})() })()
@@ -959,7 +962,7 @@ export async function buildSessionEntry(
lineProvenance.push(...renderedLines.map(() => memoryProvenance)); lineProvenance.push(...renderedLines.map(() => memoryProvenance));
} }
const content = collected.join("\n"); const content = collected.join("\n");
return { const entry: SessionFileEntry = {
path: memoryPath, path: memoryPath,
absPath, absPath,
mtimeMs, mtimeMs,
@@ -971,7 +974,9 @@ export async function buildSessionEntry(
"\n" + "\n" +
messageTimestampsMs.join(",") + messageTimestampsMs.join(",") +
"\n" + "\n" +
JSON.stringify(lineProvenance), JSON.stringify(lineProvenance) +
"\n" +
JSON.stringify(rawSource?.resetRecallCutoff ?? { state: "absent" }),
), ),
content, content,
lineMap, lineMap,
@@ -981,6 +986,13 @@ export async function buildSessionEntry(
...(generatedByDreamingNarrative ? { generatedByDreamingNarrative: true } : {}), ...(generatedByDreamingNarrative ? { generatedByDreamingNarrative: true } : {}),
...(generatedByCronRun ? { generatedByCronRun: true } : {}), ...(generatedByCronRun ? { generatedByCronRun: true } : {}),
}; };
Object.defineProperty(entry, Symbol.for("openclaw.memory.sessionResetRecallCutoff"), {
configurable: false,
enumerable: false,
value: rawSource?.resetRecallCutoff ?? { state: "absent" },
writable: false,
});
return entry;
} catch (err) { } catch (err) {
void logSessionFileReadFailure(absPath, err); void logSessionFileReadFailure(absPath, err);
return null; return null;
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { resolveSessionResetRecallCutoff } from "./session-reset-recall.js";
describe("resolveSessionResetRecallCutoff", () => {
it("uses the first kept entry before the latest reset as the live cutoff", () => {
expect(
resolveSessionResetRecallCutoff([
{ type: "message", id: "old" },
{ type: "reset", id: "first" },
{ type: "message", id: "kept" },
{ type: "message", id: "newer" },
{ type: "reset", id: "latest", firstKeptEntryId: "kept" },
]),
).toEqual({ state: "valid", cutoffLine: 3 });
});
it("uses the latest reset line when it keeps no earlier entries", () => {
expect(
resolveSessionResetRecallCutoff([
{ type: "message", id: "old" },
{ type: "reset", id: "latest" },
{ type: "message", id: "current" },
]),
).toEqual({ state: "valid", cutoffLine: 2 });
});
it.each([
[[{ type: "message", id: "only" }]],
[[{ type: "reset", id: "latest", firstKeptEntryId: "missing" }]],
[[{ type: "reset", id: "latest", firstKeptEntryId: 42 }]],
[
[
{ type: "reset", id: "latest", firstKeptEntryId: "after" },
{ type: "message", id: "after" },
],
],
])("fails closed for absent or invalid reset lineage", (events) => {
expect(resolveSessionResetRecallCutoff(events).state).not.toBe("valid");
});
});
@@ -0,0 +1,39 @@
type SessionResetRecallCutoff =
| { state: "absent" }
| { state: "invalid" }
| { cutoffLine: number; state: "valid" };
function eventId(event: unknown): string | undefined {
if (!event || typeof event !== "object" || Array.isArray(event)) {
return undefined;
}
const id = (event as { id?: unknown }).id;
return typeof id === "string" && id.trim() ? id : undefined;
}
/** Resolves the first raw transcript line owned by the current reset generation. */
export function resolveSessionResetRecallCutoff(
events: readonly unknown[],
): SessionResetRecallCutoff {
const resetIndex = events.findLastIndex(
(event) =>
event !== null &&
typeof event === "object" &&
!Array.isArray(event) &&
(event as { type?: unknown }).type === "reset",
);
if (resetIndex < 0) {
return { state: "absent" };
}
const reset = events[resetIndex] as { firstKeptEntryId?: unknown };
if (reset.firstKeptEntryId === undefined) {
return { state: "valid", cutoffLine: resetIndex + 1 };
}
if (typeof reset.firstKeptEntryId !== "string" || !reset.firstKeptEntryId.trim()) {
return { state: "invalid" };
}
const keptIndex = events.findIndex(
(event, index) => index < resetIndex && eventId(event) === reset.firstKeptEntryId,
);
return keptIndex < 0 ? { state: "invalid" } : { state: "valid", cutoffLine: keptIndex + 1 };
}
@@ -0,0 +1,557 @@
title: Remember across a private session reset
scenario:
id: remember-across-reset-private
surface: session-memory
risk: high
coverage:
primary:
- session-memory.active-memory-active-recall
- session-memory.active-memory-recall
secondary:
- session-memory.active-memory-qa-channel
- channels.channel-native-commands
objective: Verify SQLite reset continuity and pre-reset generation recall without admitting deleted, shared, or other-agent transcripts.
plugins:
- active-memory
gatewayConfigPatch:
session:
dmScope: per-channel-peer
memory:
search:
rememberAcrossConversations: true
agents:
entries:
peer:
identity:
name: QA Peer
plugins:
entries:
active-memory:
enabled: true
config:
enabled: true
mode: always
agents: []
toolsAllow:
- memory_search
logging: true
persistTranscripts: true
transcriptDir: qa-remember-across-reset-private
queryMode: message
maxSummaryChars: 220
successCriteria:
- A real Gateway session reset changes the private lifecycle revision while retaining its durable session id.
- The pre-reset fact is indexed under the canonical SQLite session identity before the current generation cutoff.
- Active Memory recalls the pre-reset private fact in the same conversation after the current reset.
- An indexed deleted private marker, shared marker, and independently indexed peer-agent marker are absent from accepted recall evidence.
docsRefs:
- docs/concepts/active-memory.md
- docs/reference/memory-config.md
- docs/reference/session-management-compaction.md
codeRefs:
- extensions/active-memory/index.ts
- extensions/memory-core/src/session-search-visibility.ts
- src/auto-reply/reply/session-reset-command.ts
- extensions/qa-lab/src/suite-runtime-flow.ts
- extensions/qa-lab/src/providers/mock-openai/server.ts
execution:
kind: flow
channel: qa-channel
providerMode: mock-openai
retryCount: 0
suiteIsolation: isolated
isolationReason: Resets and indexes isolated QA and peer-agent transcripts while running an ephemeral Gateway child.
summary: Reset one private QA conversation, index its canonical SQLite transcript generations, and prove private-only Active Memory recall in that same conversation.
config:
requiredProviderMode: mock-openai
conversationId: remember-reset-private
deletedConversationId: remember-reset-deleted
groupConversationId: remember-reset-group
peerConversationId: remember-reset-peer
privateFact: lemon pepper wings with blue cheese
deletedFact: DELETED-RESET-ONLY cinnamon popcorn with chili salt
groupFact: GROUP-RESET-ONLY loaded nachos with black olives
peerFact: PEER-RESET-ONLY smoked tofu skewers
seedMarker: QA-REMEMBER-RESET-SOURCE-SEEDED
recallPrompt: "Remember across conversations QA check: what snack do I usually want for QA movie night? Reply in one short sentence."
expectedNeedle: lemon pepper wings with blue cheese
transcriptDir: qa-remember-across-reset-private
flow:
steps:
- name: recalls the pre-reset private generation without crossing ownership boundaries
actions:
- assert:
expr: "env.providerMode === config.requiredProviderMode"
message: this deterministic reset-and-recall proof requires mock-openai
- call: waitForGatewayHealthy
args:
- ref: env
- 60000
- call: waitForQaChannelReady
args:
- ref: env
- 60000
- resetTransport: true
- call: fs.rm
args:
- expr: "path.join(env.gateway.workspaceDir, 'MEMORY.md')"
- force: true
- call: fs.rm
args:
- expr: "path.join(env.gateway.workspaceDir, 'memory', `${formatMemoryDreamingDay(Date.now())}.md`)"
- force: true
- set: sourceDelivery
value:
expr: "transport.buildAgentDelivery({ target: `dm:${config.conversationId}` })"
- set: groupDelivery
value:
expr: "transport.buildAgentDelivery({ target: `channel:${config.groupConversationId}` })"
- set: deletedDelivery
value:
expr: "transport.buildAgentDelivery({ target: `dm:${config.deletedConversationId}` })"
- set: peerDelivery
value:
expr: "transport.buildAgentDelivery({ target: `dm:${config.peerConversationId}` })"
- set: sourceSessionKey
value:
expr: "buildAgentSessionKey({ agentId: 'qa', channel: sourceDelivery.channel, accountId: transport.accountId, peer: { kind: 'direct', id: sourceDelivery.replyTo }, dmScope: env.cfg.session?.dmScope, identityLinks: env.cfg.session?.identityLinks })"
- set: groupSessionKey
value:
expr: "buildAgentSessionKey({ agentId: 'qa', channel: groupDelivery.channel, accountId: transport.accountId, peer: { kind: 'channel', id: groupDelivery.replyTo } })"
- set: deletedSessionKey
value:
expr: "buildAgentSessionKey({ agentId: 'qa', channel: deletedDelivery.channel, accountId: transport.accountId, peer: { kind: 'direct', id: deletedDelivery.replyTo }, dmScope: env.cfg.session?.dmScope, identityLinks: env.cfg.session?.identityLinks })"
- set: peerSessionKey
value:
expr: "buildAgentSessionKey({ agentId: 'peer', channel: peerDelivery.channel, accountId: transport.accountId, peer: { kind: 'direct', id: peerDelivery.replyTo }, dmScope: env.cfg.session?.dmScope, identityLinks: env.cfg.session?.identityLinks })"
- set: transcriptRoot
value:
expr: "path.join(env.gateway.tempRoot, 'state', 'plugins', 'active-memory', 'transcripts', 'agents', 'qa', config.transcriptDir)"
- call: fs.rm
args:
- ref: transcriptRoot
- recursive: true
force: true
- sendInbound:
conversation:
id:
ref: config.deletedConversationId
kind: direct
senderId:
ref: config.deletedConversationId
senderName: Remember Reset Deleted Source
text:
expr: "`Stable QA movie night usual favorite snack preference: ${config.deletedFact}. This source will be deleted. Acknowledge briefly.`"
- waitForOutbound:
conversation:
id:
ref: config.deletedConversationId
kind: direct
timeoutMs:
expr: liveTurnTimeoutMs(env, 60000)
- sendInbound:
conversation:
id:
ref: config.conversationId
kind: direct
senderId:
ref: config.conversationId
senderName: Remember Reset Source
text:
expr: "`Stable QA movie night usual favorite snack preference: ${config.privateFact}. Reply exactly: ${config.seedMarker}.`"
- waitForOutbound:
conversation:
id:
ref: config.conversationId
kind: direct
textIncludes:
ref: config.seedMarker
timeoutMs:
expr: liveTurnTimeoutMs(env, 60000)
- sendInbound:
conversation: { id: { ref: config.conversationId }, kind: direct }
senderId: { ref: config.conversationId }
senderName: Remember Reset Source
text: "Filler turn one after the saved fact. Reply briefly."
- waitForOutbound:
conversation: { id: { ref: config.conversationId }, kind: direct }
timeoutMs: { expr: "liveTurnTimeoutMs(env, 60000)" }
- sendInbound:
conversation: { id: { ref: config.conversationId }, kind: direct }
senderId: { ref: config.conversationId }
senderName: Remember Reset Source
text: "Filler turn two after the saved fact. Reply briefly."
- waitForOutbound:
conversation: { id: { ref: config.conversationId }, kind: direct }
timeoutMs: { expr: "liveTurnTimeoutMs(env, 60000)" }
- sendInbound:
conversation: { id: { ref: config.conversationId }, kind: direct }
senderId: { ref: config.conversationId }
senderName: Remember Reset Source
text: "Filler turn three after the saved fact. Reply briefly."
- waitForOutbound:
conversation: { id: { ref: config.conversationId }, kind: direct }
timeoutMs: { expr: "liveTurnTimeoutMs(env, 60000)" }
- sendInbound:
conversation: { id: { ref: config.conversationId }, kind: direct }
senderId: { ref: config.conversationId }
senderName: Remember Reset Source
text: "Filler turn four after the saved fact. Reply briefly."
- waitForOutbound:
conversation: { id: { ref: config.conversationId }, kind: direct }
timeoutMs: { expr: "liveTurnTimeoutMs(env, 60000)" }
- sendInbound:
conversation:
id:
ref: config.groupConversationId
kind: channel
title: Remember Reset Group
senderId: remember-reset-group-member
senderName: Remember Reset Group Member
text:
expr: "`@openclaw Stable QA movie night usual favorite snack preference: ${config.groupFact}. This applies only inside this group. Acknowledge briefly.`"
- waitForOutbound:
conversation:
id:
ref: config.groupConversationId
kind: channel
timeoutMs:
expr: liveTurnTimeoutMs(env, 60000)
- call: env.gateway.call
saveAs: peerStarted
args:
- agent
- agentId: peer
sessionKey:
ref: peerSessionKey
idempotencyKey:
expr: randomUUID()
message:
expr: "`Stable QA movie night usual favorite snack preference: ${config.peerFact}. This belongs only to the peer agent. Acknowledge briefly.`"
deliver: false
channel:
expr: peerDelivery.channel
to:
expr: "peerDelivery.to ?? `dm:${config.peerConversationId}`"
- timeoutMs:
expr: liveTurnTimeoutMs(env, 180000)
- assert:
expr: "Boolean(peerStarted?.runId)"
message:
expr: "`peer agent run did not start: ${JSON.stringify(peerStarted)}`"
- call: env.gateway.call
saveAs: peerWaited
args:
- agent.wait
- runId:
expr: peerStarted.runId
timeoutMs:
expr: liveTurnTimeoutMs(env, 180000)
- timeoutMs:
expr: liveTurnTimeoutMs(env, 185000)
- assert:
expr: "['ok', 'completed', 'succeeded'].includes(peerWaited?.status) || (peerWaited?.status === 'error' && String(peerWaited?.error ?? '').trim().toLowerCase() === 'completed')"
message:
expr: "`peer agent run did not complete: ${JSON.stringify(peerWaited)}`"
- call: readRawQaSessionStore
saveAs: seededStore
args:
- ref: env
- call: readRawQaSessionStore
saveAs: peerStore
args:
- ref: env
- agentId: peer
- set: seedSession
value:
expr: seededStore[sourceSessionKey]
- set: groupSession
value:
expr: seededStore[groupSessionKey]
- set: deletedSession
value:
expr: seededStore[deletedSessionKey]
- set: peerSession
value:
expr: peerStore[peerSessionKey]
- assert:
expr: "Boolean(seedSession?.sessionId) && Boolean(deletedSession?.sessionId) && Boolean(groupSession?.sessionId) && Boolean(peerSession?.sessionId)"
message:
expr: "`seeded transcript identities missing: ${JSON.stringify({ source: seedSession, group: groupSession, peer: peerSession })}`"
- call: env.gateway.call
saveAs: resetResult
args:
- sessions.reset
- key:
ref: sourceSessionKey
reason: reset
- timeoutMs:
expr: liveTurnTimeoutMs(env, 60000)
- set: resetSession
value:
expr: resetResult.entry
- assert:
expr: "resetResult?.ok === true && Boolean(resetSession?.lifecycleRevision) && resetSession.lifecycleRevision !== seedSession.lifecycleRevision"
message:
expr: "`gateway session reset did not advance lifecycle: ${JSON.stringify({ result: resetResult, before: seedSession?.lifecycleRevision })}`"
- assert:
expr: resetSession?.sessionId === seedSession.sessionId
message:
expr: "`durable session id changed across reset: ${JSON.stringify({ before: seedSession?.sessionId, after: resetSession?.sessionId })}`"
- call: readSessionTranscriptSummary
saveAs: resetTranscript
args:
- ref: env
- ref: sourceSessionKey
- probeText:
ref: config.privateFact
- assert:
expr: "Number.isInteger(resetTranscript.probeTextEndLine) && Number.isInteger(resetTranscript.resetRecallCutoffLine) && resetTranscript.probeTextEndLine < resetTranscript.resetRecallCutoffLine"
message:
expr: "`seed fact was not strictly before the effective SQLite reset cutoff: ${JSON.stringify(resetTranscript)}`"
- set: canonicalSessionPath
value:
expr: "`sessions/qa/${seedSession.sessionId}.jsonl`"
- call: env.gateway.call
saveAs: deletedResult
args:
- sessions.delete
- key:
ref: deletedSessionKey
expectedSessionId:
expr: deletedSession.sessionId
- timeoutMs:
expr: liveTurnTimeoutMs(env, 60000)
- assert:
expr: "deletedResult?.deleted === true && deletedResult.archived?.some((entry) => String(entry).includes(`${deletedSession.sessionId}.jsonl.deleted.`))"
message:
expr: "`same-agent private deletion did not produce its archive: ${JSON.stringify(deletedResult)}`"
- set: deletedArchiveName
value:
expr: "path.basename(deletedResult.archived.find((entry) => String(entry).includes(`${deletedSession.sessionId}.jsonl.deleted.`)))"
- call: readConfigSnapshot
saveAs: preconditionConfig
args:
- ref: env
- set: originalMemorySearch
value:
expr: "preconditionConfig.config.memory && typeof preconditionConfig.config.memory === 'object' ? structuredClone(preconditionConfig.config.memory.search) : undefined"
- call: patchConfig
args:
- env:
ref: env
patch:
memory:
search:
expr: "{ ...structuredClone(originalMemorySearch ?? {}), sources: ['memory', 'sessions'] }"
- call: waitForGatewayHealthy
args:
- ref: env
- 60000
- call: waitForQaChannelReady
args:
- ref: env
- 60000
- call: runQaCli
args:
- ref: env
- - memory
- index
- --agent
- peer
- --force
- timeoutMs:
expr: liveTurnTimeoutMs(env, 60000)
- call: runQaCli
saveAs: peerSearch
args:
- ref: env
- - memory
- search
- --agent
- peer
- --json
- --query
- expr: config.peerFact
- --max-results
- "50"
- --min-score
- "0"
- timeoutMs:
expr: liveTurnTimeoutMs(env, 60000)
json: true
- assert:
expr: "JSON.stringify(peerSearch).includes(config.peerFact) && JSON.stringify(peerSearch).includes(peerSession.sessionId)"
message:
expr: "`peer agent index did not contain its marker: ${JSON.stringify(peerSearch)}`"
- call: runQaCli
args:
- ref: env
- - memory
- index
- --agent
- qa
- --force
- timeoutMs:
expr: liveTurnTimeoutMs(env, 60000)
- call: runQaCli
saveAs: qaSearch
args:
- ref: env
- - memory
- search
- --agent
- qa
- --json
- --query
- expr: config.privateFact
- --max-results
- "50"
- --min-score
- "0"
- timeoutMs:
expr: liveTurnTimeoutMs(env, 60000)
json: true
- set: qaSearchText
value:
expr: JSON.stringify(qaSearch)
- call: runQaCli
saveAs: groupSearch
args:
- ref: env
- - memory
- search
- --agent
- qa
- --json
- --query
- expr: config.groupFact
- --max-results
- "50"
- --min-score
- "0"
- timeoutMs:
expr: liveTurnTimeoutMs(env, 60000)
json: true
- call: runQaCli
saveAs: deletedSearch
args:
- ref: env
- - memory
- search
- --agent
- qa
- --json
- --query
- expr: config.deletedFact
- --max-results
- "50"
- --min-score
- "0"
- timeoutMs:
expr: liveTurnTimeoutMs(env, 60000)
json: true
- set: deletedSearchText
value:
expr: JSON.stringify(deletedSearch)
- set: groupSearchText
value:
expr: JSON.stringify(groupSearch)
- assert:
expr: "groupSearchText.includes(config.groupFact) && groupSearchText.includes(groupSession.sessionId)"
message:
expr: "`QA index did not contain the seeded group transcript: ${groupSearchText}`"
- assert:
expr: "deletedSearchText.includes(config.deletedFact) && deletedSearchText.includes(deletedArchiveName)"
message:
expr: "`QA index did not contain the deleted private control: ${deletedSearchText}`"
- assert:
expr: "qaSearchText.includes(config.privateFact) && qaSearchText.includes(canonicalSessionPath)"
message:
expr: "`QA index did not return the pre-reset private fact at its canonical SQLite path: ${qaSearchText}`"
- call: patchConfig
args:
- env:
ref: env
patch:
memory:
search:
expr: "originalMemorySearch === undefined ? null : structuredClone(originalMemorySearch)"
- call: waitForGatewayHealthy
args:
- ref: env
- 60000
- call: waitForQaChannelReady
args:
- ref: env
- 60000
- call: env.gateway.restartAfterStateMutation
args:
- lambda:
async: true
expr: await Promise.resolve()
- call: fs.rm
args:
- ref: transcriptRoot
- recursive: true
force: true
- set: requestCursorBeforeRecall
value:
expr: "(await fetchJson(`${env.mock.baseUrl}/debug/request-cursor`)).cursor"
- set: recallOutboundIndex
value:
expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound').length"
- sendInbound:
conversation:
id:
ref: config.conversationId
kind: direct
senderId:
ref: config.conversationId
senderName: Remember Reset Source
text:
ref: config.recallPrompt
- call: waitForOutboundMessage
saveAs: recallOutbound
args:
- ref: state
- lambda:
params: [candidate]
expr: "candidate.direction === 'outbound' && candidate.conversation.id === config.conversationId"
- expr: liveTurnTimeoutMs(env, 60000)
- sinceIndex:
ref: recallOutboundIndex
- call: waitForCondition
saveAs: helperTranscriptPath
args:
- lambda:
async: true
expr: "await (async () => { const entries = (await fs.readdir(transcriptRoot).catch(() => [])).filter((entry) => entry.endsWith('.jsonl')).toSorted(); return entries.length > 0 ? path.join(transcriptRoot, entries.at(-1)) : undefined; })()"
- expr: liveTurnTimeoutMs(env, 30000)
- 250
- call: fs.readFile
saveAs: helperTranscriptText
args:
- ref: helperTranscriptPath
- utf8
- set: recallRequests
value:
expr: "await fetchJson(`${env.mock.baseUrl}/debug/requests?after=${requestCursorBeforeRecall}`)"
- assert:
expr: "normalizeLowercaseStringOrEmpty(recallOutbound.text).includes(normalizeLowercaseStringOrEmpty(config.expectedNeedle))"
message:
expr: "`same-conversation post-reset reply missed the pre-reset private fact: ${recallOutbound.text}`"
- assert:
expr: "helperTranscriptText.includes('memory_search') && helperTranscriptText.includes(config.privateFact) && helperTranscriptText.includes(seedSession.sessionId)"
message:
expr: "`Active Memory helper did not accept the canonical pre-reset private fact: ${helperTranscriptText}`"
- assert:
expr: "!helperTranscriptText.includes(config.deletedFact) && !helperTranscriptText.includes(deletedArchiveName) && !helperTranscriptText.includes(config.groupFact) && !helperTranscriptText.includes(groupSession.sessionId) && !helperTranscriptText.includes(config.peerFact) && !helperTranscriptText.includes(peerSession.sessionId)"
message:
expr: "`Active Memory helper crossed a shared or peer-agent boundary: ${helperTranscriptText}`"
- assert:
expr: "recallRequests.some((request) => String(request.allInputText ?? '').includes('Remember across conversations QA check') && request.plannedToolName === 'memory_search')"
message: deterministic post-reset recall did not issue memory_search
detailsExpr: "JSON.stringify({ verdict: 'PASS', scenario: 'remember-across-reset-private', channel: 'qa-channel', provider: env.providerMode, gateway: 'ephemeral-child', lifecycleReset: resetSession.lifecycleRevision !== seedSession.lifecycleRevision, durableSessionRetained: resetSession.sessionId === seedSession.sessionId, canonicalResetBoundary: true, preResetHitIndexed: qaSearchText.includes(canonicalSessionPath), preResetHitAccepted: helperTranscriptText.includes(seedSession.sessionId), deletedTranscriptIndexed: deletedSearchText.includes(deletedArchiveName), deletedTranscriptExcluded: !helperTranscriptText.includes(deletedArchiveName), groupTranscriptIndexed: groupSearchText.includes(groupSession.sessionId), peerTranscriptIndexed: JSON.stringify(peerSearch).includes(peerSession.sessionId), privateRecallDelivered: normalizeLowercaseStringOrEmpty(recallOutbound.text).includes(normalizeLowercaseStringOrEmpty(config.expectedNeedle)), sharedExcluded: !helperTranscriptText.includes(groupSession.sessionId), peerAgentExcluded: !helperTranscriptText.includes(peerSession.sessionId), reply: recallOutbound.text })"