Skip to content

Commit 2ffdb5d

Browse files
authored
fix(memory): keep archive transcript visibility safe
Keep reset/deleted session archives searchable while preserving visibility filtering, and keep internal cron-run archives opaque when live ownership metadata is gone.\n\nRefs #56131.\nThanks @buyitsydney.
1 parent d583662 commit 2ffdb5d

7 files changed

Lines changed: 361 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai
5959
- Cron: preserve manual `cron.run` IDs in `cron.runs` history so manual run acknowledgements can be correlated with finished run records. Fixes #76276.
6060
- CLI/devices: request `operator.admin` for `openclaw devices approve <requestId>` only when the exact pending device request would mint or inherit admin-scoped operator access, while keeping lower-scope approvals on the pairing scope.
6161
- Memory/embedding: broaden the embedding reindex retry classifier to include transient socket-layer errors (`fetch failed`, `ECONNRESET`, `socket hang up`, `UND_ERR_*`, `closed`) so memory reindex survives provider network hiccups instead of aborting mid-run. Related #56815, #44166. (#76311) Thanks @buyitsydney.
62+
- Memory/sessions: keep rotated and deleted session transcripts (`.jsonl.reset.<iso>` / `.jsonl.deleted.<iso>`) searchable end-to-end by indexing their real content in `buildSessionEntry` instead of short-circuiting to empty entries, and by mapping archive hit paths back to their live transcript stem during `memory_search` visibility filtering so hits are no longer dropped at the guard. `.jsonl.bak.<iso>` backups and compaction checkpoints remain opaque. Refs #56131. Thanks @buyitsydney.
6263
- Memory/search: keep sqlite-vec optional in packaged installs and point missing-extension recovery at the valid `agents.defaults.memorySearch.store.vector.extensionPath` setting. Thanks @willemsej and @vincentkoc.
6364
- Gateway: keep directly requested plugin tools invokable under restrictive tool profiles while preserving explicit deny lists and the HTTP safety deny list, preventing catalog/invoke mismatches that surface as "Tool not available". Thanks @BunsDev.
6465
- Gateway/update: allow beta binaries to refresh gateway services when the config was last written by the matching stable release version, avoiding false newer-config downgrade blocks during beta channel updates.

extensions/memory-core/src/session-search-visibility.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const crossAgentStore = {
1111
sessionFile: "/tmp/sessions/w1.jsonl",
1212
},
1313
};
14+
let combinedSessionStore: typeof crossAgentStore | Record<string, never> = crossAgentStore;
1415

1516
vi.mock("openclaw/plugin-sdk/session-transcript-hit", async (importOriginal) => {
1617
const actual =
@@ -19,14 +20,15 @@ vi.mock("openclaw/plugin-sdk/session-transcript-hit", async (importOriginal) =>
1920
...actual,
2021
loadCombinedSessionStoreForGateway: vi.fn(() => ({
2122
storePath: "(test)",
22-
store: crossAgentStore,
23+
store: combinedSessionStore,
2324
})),
2425
};
2526
});
2627

2728
describe("filterMemorySearchHitsBySessionVisibility", () => {
2829
afterEach(() => {
2930
vi.mocked(sessionTranscriptHit.loadCombinedSessionStoreForGateway).mockClear();
31+
combinedSessionStore = crossAgentStore;
3032
});
3133

3234
it("drops sessions-sourced hits when requester key is missing (fail closed)", async () => {
@@ -148,4 +150,57 @@ describe("filterMemorySearchHitsBySessionVisibility", () => {
148150
});
149151
expect(filtered).toEqual([]);
150152
});
153+
154+
it("keeps same-agent deleted archive hits using owner metadata when the live store entry is gone", async () => {
155+
combinedSessionStore = {};
156+
const hit: MemorySearchResult = {
157+
path: "sessions/main/deleted-stem.jsonl.deleted.2026-02-16T22-27-33.000Z",
158+
source: "sessions",
159+
score: 1,
160+
snippet: "x",
161+
startLine: 1,
162+
endLine: 2,
163+
};
164+
const cfg = asOpenClawConfig({
165+
tools: {
166+
sessions: { visibility: "agent" },
167+
},
168+
});
169+
170+
const filtered = await filterMemorySearchHitsBySessionVisibility({
171+
cfg,
172+
requesterSessionKey: "agent:main:main",
173+
sandboxed: false,
174+
hits: [hit],
175+
});
176+
177+
expect(filtered).toEqual([hit]);
178+
});
179+
180+
it("still denies cross-agent deleted archive hits resolved from owner metadata when a2a is disabled", async () => {
181+
combinedSessionStore = {};
182+
const hit: MemorySearchResult = {
183+
path: "sessions/peer/deleted-stem.jsonl.deleted.2026-02-16T22-27-33.000Z",
184+
source: "sessions",
185+
score: 1,
186+
snippet: "x",
187+
startLine: 1,
188+
endLine: 2,
189+
};
190+
const cfg = asOpenClawConfig({
191+
tools: {
192+
sessions: { visibility: "all" },
193+
agentToAgent: { enabled: false },
194+
},
195+
});
196+
197+
const filtered = await filterMemorySearchHitsBySessionVisibility({
198+
cfg,
199+
requesterSessionKey: "agent:main:main",
200+
sandboxed: false,
201+
hits: [hit],
202+
});
203+
204+
expect(filtered).toEqual([]);
205+
});
151206
});

extensions/memory-core/src/session-search-visibility.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
22
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
33
import {
4-
extractTranscriptStemFromSessionsMemoryHit,
4+
extractTranscriptIdentityFromSessionsMemoryHit,
55
loadCombinedSessionStoreForGateway,
66
resolveTranscriptStemToSessionKeys,
77
} from "openclaw/plugin-sdk/session-transcript-hit";
@@ -42,13 +42,16 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
4242
if (!params.requesterSessionKey || !guard) {
4343
continue;
4444
}
45-
const stem = extractTranscriptStemFromSessionsMemoryHit(hit.path);
46-
if (!stem) {
45+
const identity = extractTranscriptIdentityFromSessionsMemoryHit(hit.path);
46+
if (!identity) {
4747
continue;
4848
}
4949
const keys = resolveTranscriptStemToSessionKeys({
5050
store: combinedSessionStore,
51-
stem,
51+
stem: identity.stem,
52+
...(identity.archived && identity.ownerAgentId
53+
? { archivedOwnerAgentId: identity.ownerAgentId }
54+
: {}),
5255
});
5356
if (keys.length === 0) {
5457
continue;

packages/memory-host-sdk/src/host/session-files.test.ts

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import fsSync from "node:fs";
22
import os from "node:os";
33
import path from "node:path";
44
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
5-
import { buildSessionEntry, listSessionFilesForAgent } from "./session-files.js";
5+
import {
6+
buildSessionEntry,
7+
listSessionFilesForAgent,
8+
sessionPathForFile,
9+
} from "./session-files.js";
610

711
let fixtureRoot: string;
812
let tmpDir: string;
@@ -61,6 +65,28 @@ describe("listSessionFilesForAgent", () => {
6165
});
6266
});
6367

68+
describe("sessionPathForFile", () => {
69+
it("includes the owning agent id when the transcript lives under an agent sessions dir", () => {
70+
const absPath = path.join(
71+
tmpDir,
72+
"agents",
73+
"main",
74+
"sessions",
75+
"deleted-session.jsonl.deleted.2026-02-16T22-27-33.000Z",
76+
);
77+
78+
expect(sessionPathForFile(absPath)).toBe(
79+
"sessions/main/deleted-session.jsonl.deleted.2026-02-16T22-27-33.000Z",
80+
);
81+
});
82+
83+
it("keeps the legacy basename-only path when the agent owner cannot be derived", () => {
84+
expect(sessionPathForFile(path.join(tmpDir, "loose-session.jsonl"))).toBe(
85+
"sessions/loose-session.jsonl",
86+
);
87+
});
88+
});
89+
6490
describe("buildSessionEntry", () => {
6591
it("returns lineMap tracking original JSONL line numbers", async () => {
6692
// Simulate a real session JSONL file with metadata records interspersed
@@ -116,30 +142,92 @@ describe("buildSessionEntry", () => {
116142
expect(entry!.lineMap).toEqual([]);
117143
});
118144

119-
it("skips deleted and checkpoint transcripts for dreaming ingestion", async () => {
145+
it("indexes usage-counted reset/deleted archives but still skips bak and checkpoint artifacts", async () => {
146+
const resetPath = path.join(tmpDir, "ordinary.jsonl.reset.2026-02-16T22-26-33.000Z");
120147
const deletedPath = path.join(tmpDir, "ordinary.jsonl.deleted.2026-02-16T22-27-33.000Z");
148+
const bakPath = path.join(tmpDir, "ordinary.jsonl.bak.2026-02-16T22-28-33.000Z");
121149
const checkpointPath = path.join(
122150
tmpDir,
123151
"ordinary.checkpoint.11111111-1111-4111-8111-111111111111.jsonl",
124152
);
125153
const content = JSON.stringify({
126154
type: "message",
127-
message: { role: "user", content: "This should never reach the dreaming corpus." },
155+
message: { role: "user", content: "Archived hello" },
128156
});
157+
fsSync.writeFileSync(resetPath, content);
129158
fsSync.writeFileSync(deletedPath, content);
159+
fsSync.writeFileSync(bakPath, content);
130160
fsSync.writeFileSync(checkpointPath, content);
131161

162+
const resetEntry = await buildSessionEntry(resetPath);
132163
const deletedEntry = await buildSessionEntry(deletedPath);
164+
const bakEntry = await buildSessionEntry(bakPath);
133165
const checkpointEntry = await buildSessionEntry(checkpointPath);
134166

135-
expect(deletedEntry).not.toBeNull();
136-
expect(deletedEntry?.content).toBe("");
137-
expect(deletedEntry?.lineMap).toEqual([]);
167+
// Usage-counted archives (reset, deleted) must surface real content so
168+
// post-reset memory_search can recover prior session history.
169+
expect(resetEntry?.content).toContain("User: Archived hello");
170+
expect(resetEntry?.lineMap).toEqual([1]);
171+
expect(deletedEntry?.content).toContain("User: Archived hello");
172+
expect(deletedEntry?.lineMap).toEqual([1]);
173+
174+
// .bak and compaction checkpoints remain opaque pre-archive / snapshot
175+
// artifacts and stay empty so they do not get double-indexed.
176+
expect(bakEntry).not.toBeNull();
177+
expect(bakEntry?.content).toBe("");
178+
expect(bakEntry?.lineMap).toEqual([]);
138179
expect(checkpointEntry).not.toBeNull();
139180
expect(checkpointEntry?.content).toBe("");
140181
expect(checkpointEntry?.lineMap).toEqual([]);
141182
});
142183

184+
it("keeps cron-run deleted archives opaque when the live session store entry is gone", async () => {
185+
const archivePath = path.join(tmpDir, "cron-run.jsonl.deleted.2026-02-16T22-27-33.000Z");
186+
const jsonlLines = [
187+
JSON.stringify({
188+
type: "message",
189+
message: {
190+
role: "user",
191+
content: "[cron:job-1 Codex Sessions Sync] Run internal sync.",
192+
},
193+
}),
194+
JSON.stringify({
195+
type: "message",
196+
message: { role: "assistant", content: "Internal cron output that must stay out." },
197+
}),
198+
];
199+
fsSync.writeFileSync(archivePath, jsonlLines.join("\n"));
200+
201+
const entry = await buildSessionEntry(archivePath);
202+
203+
expect(entry).not.toBeNull();
204+
expect(entry?.content).toBe("");
205+
expect(entry?.lineMap).toEqual([]);
206+
expect(entry?.generatedByCronRun).toBe(true);
207+
});
208+
209+
it("keeps cron-run reset archives opaque when session metadata preserves the cron key", async () => {
210+
const archivePath = path.join(tmpDir, "cron-run.jsonl.reset.2026-02-16T22-26-33.000Z");
211+
const jsonlLines = [
212+
JSON.stringify({
213+
type: "session-meta",
214+
data: { sessionKey: "agent:main:cron:job-1:run:run-1" },
215+
}),
216+
JSON.stringify({
217+
type: "message",
218+
message: { role: "assistant", content: "Internal cron output that must stay out." },
219+
}),
220+
];
221+
fsSync.writeFileSync(archivePath, jsonlLines.join("\n"));
222+
223+
const entry = await buildSessionEntry(archivePath);
224+
225+
expect(entry).not.toBeNull();
226+
expect(entry?.content).toBe("");
227+
expect(entry?.lineMap).toEqual([]);
228+
expect(entry?.generatedByCronRun).toBe(true);
229+
});
230+
143231
it("skips blank lines and invalid JSON without breaking lineMap", async () => {
144232
const jsonlLines = [
145233
"",

0 commit comments

Comments
 (0)