Skip to content

Commit 40a5942

Browse files
tanshanshantanshanshan
andauthored
fix(memory): keep qmd archived session hits visible
Keep QMD-exported archived session transcript hits visible by resolving QMD `.md` archive stems back to their live session ids before applying session visibility policy. Preserve normal markdown session ids that only resemble archive names, reject ambiguous slug fallback matches, and keep deleted same-agent QMD archives readable when the live store entry is gone. Fixes #83506. Co-authored-by: tanshanshan <tanshanshan@users.noreply.github.com>
1 parent b823a5a commit 40a5942

7 files changed

Lines changed: 488 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai
5858
- Release stability: recover stale session diagnostics and Codex OAuth fallback state so stuck runs and reused refresh tokens clear without blocking follow-up work. (#83503) Thanks @100yenadmin.
5959
- Messages/TTS: apply TTS directives before message-tool sends reach core, gateway, or plugin delivery so opt-in message-tool rooms and proactive sends attach voice notes instead of leaking raw tags. Fixes #81598. Thanks @CG-Intelligence-Agent-Jack and @CoronovirusG10.
6060
- Messages/Codex: keep Codex direct/source chats on message-tool visible delivery by default while documenting and testing `messages.visibleReplies: "automatic"` as the old-mode opt-out; channel wildcard model overrides now apply to direct chats before harness delivery defaults.
61+
- Memory/QMD: keep archived session transcript hits visible after QMD export while preserving normal `.md` session ids that only resemble archive names. (#83518; fixes #83506) Thanks @tanshanshan.
6162
- Codex app-server: preserve network access for sandboxed Codex code-mode turns when the OpenClaw sandbox allows outbound egress. Fixes #83347. Thanks @YusukeIt0.
6263
- QA-Lab: keep the OTLP smoke decoder independent of removed OpenTelemetry generated-root internals.
6364
- Messages: default group/channel visible replies to automatic final delivery again, keeping `message_tool` opt-in for ambient/shared rooms and tool-reliable models.

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

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,4 +359,127 @@ describe("filterMemorySearchHitsBySessionVisibility", () => {
359359

360360
expect(filtered).toStrictEqual([]);
361361
});
362+
363+
it("keeps same-agent QMD-normalized archived reset .md hits when the store has a matching entry", async () => {
364+
combinedSessionStore = {
365+
"agent:main:abc-uuid": {
366+
sessionId: "abc-uuid",
367+
updatedAt: 1,
368+
sessionFile: "/tmp/sessions/abc-uuid.jsonl",
369+
},
370+
};
371+
const hit: MemorySearchResult = {
372+
path: "qmd/sessions-main/abc-uuid-jsonl-reset-2026-02-16t22-26-33-000z.md",
373+
source: "sessions",
374+
score: 1,
375+
snippet: "x",
376+
startLine: 1,
377+
endLine: 2,
378+
};
379+
const cfg = asOpenClawConfig({
380+
tools: {
381+
sessions: { visibility: "agent" },
382+
},
383+
});
384+
385+
const filtered = await filterMemorySearchHitsBySessionVisibility({
386+
cfg,
387+
requesterSessionKey: "agent:main:main",
388+
sandboxed: false,
389+
hits: [hit],
390+
});
391+
392+
expect(filtered).toEqual([hit]);
393+
});
394+
395+
it("keeps QMD .md hits whose live session id looks like an archive name", async () => {
396+
const sessionId = "foo.jsonl.deleted.2026-02-16T22-27-33.000Z";
397+
combinedSessionStore = {
398+
"agent:main:archive-looking": {
399+
sessionId,
400+
updatedAt: 1,
401+
sessionFile: `/tmp/sessions/${sessionId}.jsonl`,
402+
},
403+
};
404+
const hit: MemorySearchResult = {
405+
path: `qmd/sessions-main/${sessionId}.md`,
406+
source: "sessions",
407+
score: 1,
408+
snippet: "x",
409+
startLine: 1,
410+
endLine: 2,
411+
};
412+
const cfg = asOpenClawConfig({
413+
tools: {
414+
sessions: { visibility: "self" },
415+
},
416+
});
417+
418+
const filtered = await filterMemorySearchHitsBySessionVisibility({
419+
cfg,
420+
requesterSessionKey: "agent:main:archive-looking",
421+
sandboxed: false,
422+
hits: [hit],
423+
});
424+
425+
expect(filtered).toEqual([hit]);
426+
});
427+
428+
it("does not authorize QMD archived .md hits through lossy slug fallback", async () => {
429+
combinedSessionStore = {
430+
"agent:main:foo_bar": {
431+
sessionId: "foo_bar",
432+
updatedAt: 1,
433+
sessionFile: "/tmp/sessions/foo_bar.jsonl",
434+
},
435+
};
436+
const hit: MemorySearchResult = {
437+
path: "qmd/sessions-main/foo-bar-jsonl-deleted-2026-02-16t22-26-33-000z.md",
438+
source: "sessions",
439+
score: 1,
440+
snippet: "x",
441+
startLine: 1,
442+
endLine: 2,
443+
};
444+
const cfg = asOpenClawConfig({
445+
tools: {
446+
sessions: { visibility: "self" },
447+
},
448+
});
449+
450+
const filtered = await filterMemorySearchHitsBySessionVisibility({
451+
cfg,
452+
requesterSessionKey: "agent:main:foo_bar",
453+
sandboxed: false,
454+
hits: [hit],
455+
});
456+
457+
expect(filtered).toStrictEqual([]);
458+
});
459+
460+
it("keeps same-agent QMD archived deleted .md hits when no store entry remains", async () => {
461+
combinedSessionStore = {};
462+
const hit: MemorySearchResult = {
463+
path: "qmd/sessions-main/abc-uuid-jsonl-deleted-2026-02-16t22-26-33-000z.md",
464+
source: "sessions",
465+
score: 1,
466+
snippet: "x",
467+
startLine: 1,
468+
endLine: 2,
469+
};
470+
const cfg = asOpenClawConfig({
471+
tools: {
472+
sessions: { visibility: "all" },
473+
},
474+
});
475+
476+
const filtered = await filterMemorySearchHitsBySessionVisibility({
477+
cfg,
478+
requesterSessionKey: "agent:main:main",
479+
sandboxed: false,
480+
hits: [hit],
481+
});
482+
483+
expect(filtered).toEqual([hit]);
484+
});
362485
});

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

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
8787
if (!identity) {
8888
continue;
8989
}
90+
const isQmdSessionHit = hit.path.replace(/\\/g, "/").startsWith("qmd/");
9091
const normalizedScopedAgentId = normalizeAgentIdForCompare(scopedAgentId);
9192
const normalizedOwnerAgentId = normalizeAgentIdForCompare(identity.ownerAgentId);
9293
if (
@@ -98,20 +99,34 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
9899
}
99100
const archivedOwnerMatchesScope = Boolean(
100101
identity.archived &&
101-
identity.ownerAgentId &&
102-
(!scopedAgentId ||
103-
normalizeAgentIdForCompare(identity.ownerAgentId) ===
104-
normalizeAgentIdForCompare(scopedAgentId)),
102+
((identity.ownerAgentId &&
103+
(!scopedAgentId ||
104+
normalizeAgentIdForCompare(identity.ownerAgentId) ===
105+
normalizeAgentIdForCompare(scopedAgentId))) ||
106+
(isQmdSessionHit && scopedAgentId)),
105107
);
106-
const archivedOwnerAgentId = archivedOwnerMatchesScope ? identity.ownerAgentId : undefined;
108+
const archivedOwnerAgentId = archivedOwnerMatchesScope
109+
? (identity.ownerAgentId ?? scopedAgentId)
110+
: undefined;
111+
const liveKeys = identity.liveStem
112+
? resolveTranscriptStemToSessionKeys({
113+
store: combinedSessionStore,
114+
stem: identity.liveStem,
115+
allowQmdSlugFallback: false,
116+
})
117+
: [];
107118
const keys = filterSessionKeysByScopedAgent({
108119
cfg: params.cfg,
109120
scopedAgentId,
110-
keys: resolveTranscriptStemToSessionKeys({
111-
store: combinedSessionStore,
112-
stem: identity.stem,
113-
...(archivedOwnerAgentId ? { archivedOwnerAgentId } : {}),
114-
}),
121+
keys:
122+
liveKeys.length > 0
123+
? liveKeys
124+
: resolveTranscriptStemToSessionKeys({
125+
store: combinedSessionStore,
126+
stem: identity.stem,
127+
allowQmdSlugFallback: isQmdSessionHit && !identity.archived,
128+
...(archivedOwnerAgentId ? { archivedOwnerAgentId } : {}),
129+
}),
115130
});
116131
if (keys.length === 0) {
117132
continue;

extensions/memory-wiki/src/query.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -820,6 +820,59 @@ describe("searchMemoryWiki", () => {
820820
]);
821821
});
822822

823+
it("keeps QMD archived session search hits inside visibility policy", async () => {
824+
const { config } = await createQueryVault({
825+
initialize: true,
826+
config: {
827+
search: { backend: "shared", corpus: "memory" },
828+
},
829+
});
830+
loadCombinedSessionStoreForGatewayMock.mockReturnValue({
831+
storePath: "(test)",
832+
store: {
833+
"agent:main:abc-uuid": {
834+
sessionId: "abc-uuid",
835+
updatedAt: 1,
836+
sessionFile: "/tmp/openclaw/abc-uuid.jsonl",
837+
},
838+
},
839+
});
840+
const manager = createMemoryManager({
841+
searchResults: [
842+
{
843+
path: "qmd/sessions-main/abc-uuid-jsonl-reset-2026-02-16t22-26-33-000z.md",
844+
startLine: 1,
845+
endLine: 2,
846+
score: 30,
847+
snippet: "archived transcript",
848+
source: "sessions",
849+
},
850+
{
851+
path: "abc-uuid-jsonl-reset-2026-02-16t22-26-33-000z.md",
852+
startLine: 3,
853+
endLine: 4,
854+
score: 20,
855+
snippet: "normal markdown",
856+
source: "sessions",
857+
},
858+
],
859+
});
860+
getActiveMemorySearchManagerMock.mockResolvedValue({ manager });
861+
862+
const results = await searchMemoryWiki({
863+
config,
864+
appConfig: createSessionVisibilityAppConfig(),
865+
agentSessionKey: "agent:main:abc-uuid",
866+
sandboxed: true,
867+
query: "transcript",
868+
maxResults: 10,
869+
});
870+
871+
expect(results.map((result) => result.path)).toEqual([
872+
"qmd/sessions-main/abc-uuid-jsonl-reset-2026-02-16t22-26-33-000z.md",
873+
]);
874+
});
875+
823876
it("scopes gateway-style session memory search by agent", async () => {
824877
const { config } = await createQueryVault({
825878
initialize: true,
@@ -1441,6 +1494,42 @@ describe("getMemoryWikiPage", () => {
14411494
});
14421495
});
14431496

1497+
it("permits QMD archived deleted session reads when the live store entry is gone", async () => {
1498+
const { config } = await createQueryVault({
1499+
initialize: true,
1500+
config: {
1501+
search: { backend: "shared", corpus: "memory" },
1502+
},
1503+
});
1504+
loadCombinedSessionStoreForGatewayMock.mockReturnValue({ storePath: "(test)", store: {} });
1505+
const manager = createMemoryManager({
1506+
readResult: {
1507+
path: "qmd/sessions-main/deleted-uuid-jsonl-deleted-2026-02-16t22-26-33-000z.md",
1508+
text: "deleted archive transcript",
1509+
},
1510+
});
1511+
getActiveMemorySearchManagerMock.mockResolvedValue({ manager });
1512+
1513+
const result = await getMemoryWikiPage({
1514+
config,
1515+
appConfig: createSessionVisibilityAppConfig(),
1516+
agentSessionKey: "agent:main:deleted-uuid",
1517+
sandboxed: true,
1518+
lookup: "qmd/sessions-main/deleted-uuid-jsonl-deleted-2026-02-16t22-26-33-000z.md",
1519+
});
1520+
1521+
expectFields(result, {
1522+
corpus: "memory",
1523+
path: "qmd/sessions-main/deleted-uuid-jsonl-deleted-2026-02-16t22-26-33-000z.md",
1524+
content: "deleted archive transcript",
1525+
});
1526+
expect(manager.readFile).toHaveBeenCalledWith({
1527+
relPath: "qmd/sessions-main/deleted-uuid-jsonl-deleted-2026-02-16t22-26-33-000z.md",
1528+
from: 1,
1529+
lines: 200,
1530+
});
1531+
});
1532+
14441533
it("requires appConfig for session-bound shared memory reads", async () => {
14451534
const { config } = await createQueryVault({
14461535
initialize: true,

extensions/memory-wiki/src/query.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1291,6 +1291,7 @@ async function createSessionMemoryPathVisibilityChecker(params: {
12911291
if (!identity) {
12921292
return false;
12931293
}
1294+
const isQmdSessionPath = relPath.replace(/\\/g, "/").startsWith("qmd/");
12941295
const normalizedScopedAgentId = normalizeLowercaseStringOrEmpty(scopedAgentId);
12951296
const normalizedOwnerAgentId = normalizeLowercaseStringOrEmpty(identity.ownerAgentId);
12961297
if (
@@ -1302,18 +1303,32 @@ async function createSessionMemoryPathVisibilityChecker(params: {
13021303
}
13031304
const archivedOwnerMatchesScope = Boolean(
13041305
identity.archived &&
1305-
identity.ownerAgentId &&
1306-
(!normalizedScopedAgentId || normalizedOwnerAgentId === normalizedScopedAgentId),
1306+
((identity.ownerAgentId &&
1307+
(!normalizedScopedAgentId || normalizedOwnerAgentId === normalizedScopedAgentId)) ||
1308+
(isQmdSessionPath && scopedAgentId)),
13071309
);
1308-
const archivedOwnerAgentId = archivedOwnerMatchesScope ? identity.ownerAgentId : undefined;
1310+
const archivedOwnerAgentId = archivedOwnerMatchesScope
1311+
? (identity.ownerAgentId ?? scopedAgentId)
1312+
: undefined;
1313+
const liveKeys = identity.liveStem
1314+
? resolveTranscriptStemToSessionKeys({
1315+
store: combinedSessionStore,
1316+
stem: identity.liveStem,
1317+
allowQmdSlugFallback: false,
1318+
})
1319+
: [];
13091320
const keys = filterSessionKeysByScopedAgent({
13101321
cfg: params.cfg,
13111322
scopedAgentId,
1312-
keys: resolveTranscriptStemToSessionKeys({
1313-
store: combinedSessionStore,
1314-
stem: identity.stem,
1315-
...(archivedOwnerAgentId ? { archivedOwnerAgentId } : {}),
1316-
}),
1323+
keys:
1324+
liveKeys.length > 0
1325+
? liveKeys
1326+
: resolveTranscriptStemToSessionKeys({
1327+
store: combinedSessionStore,
1328+
stem: identity.stem,
1329+
allowQmdSlugFallback: isQmdSessionPath && !identity.archived,
1330+
...(archivedOwnerAgentId ? { archivedOwnerAgentId } : {}),
1331+
}),
13171332
});
13181333
if (!guard) {
13191334
return Boolean(scopedAgentId && keys.length > 0);

0 commit comments

Comments
 (0)