fix(memory): keep qmd session paths roundtrip-safe (#57560)

This commit is contained in:
Vincent Koc
2026-03-30 18:57:03 +09:00
committed by GitHub
parent c7d0beb98d
commit 118a497496
3 changed files with 111 additions and 1 deletions

View File

@@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai
- Memory/QMD: rebind collections when QMD reports a changed pattern but omits path metadata, so config pattern changes stop being silently ignored on restart. (#49897) Thanks @Madruru.
- Memory/QMD: warn explicitly when `memory.backend=qmd` is configured but the `qmd` binary is missing, so doctor and runtime fallback no longer fail as a silent builtin downgrade. (#50439) Thanks @Jimmy-xuzimo and @vincentkoc.
- Memory/QMD: pass a direct-session key on `openclaw memory search` so CLI QMD searches no longer get denied as `session=<none>` under direct-only scope defaults. (#43517) Thanks @waynecc-at and @vincentkoc.
- Memory/QMD: keep `memory_search` session-hit paths roundtrip-safe when exported session markdown lives under the workspace `qmd/` directory, so `memory_get` can read the exact returned path instead of failing on the generic `qmd/sessions/...` alias. (#43519) Thanks @holgergruenhagen and @vincentkoc.
- Agents/memory flush: keep daily memory flush files append-only during embedded attempts so compaction writes do not overwrite earlier notes. (#53725) Thanks @HPluseven.
- Web UI/markdown: stop bare auto-links from swallowing adjacent CJK text while preserving valid mixed-script path and query characters in rendered links. (#48410) Thanks @jnuyao.
- BlueBubbles/iMessage: coalesce URL-only inbound messages with their link-preview balloon again so sharing a bare link no longer drops the URL from agent context. Thanks @vincentkoc.

View File

@@ -182,6 +182,15 @@ describe("QmdMemoryManager", () => {
// install explicit shim fixtures inline.
cfg = {
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
provider: "openai",
model: "mock-embed",
store: { path: path.join(workspaceDir, "index.sqlite"), vector: { enabled: false } },
sync: { watch: false, onSessionStart: false, onSearch: false },
},
},
list: [{ id: agentId, default: true, workspace: workspaceDir }],
},
memory: {
@@ -3368,6 +3377,99 @@ describe("QmdMemoryManager", () => {
await manager.close();
});
it("returns collection-scoped qmd paths when session exports live under the workspace qmd directory", async () => {
workspaceDir = path.join(stateDir, "agents", agentId);
await fs.mkdir(workspaceDir, { recursive: true });
cfg = {
agents: {
list: [{ id: agentId, default: true, workspace: workspaceDir }],
},
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") {
const child = createMockChild({ autoClose: false });
emitAndClose(
child,
"stdout",
JSON.stringify([
{
file: "qmd://sessions-main/session-1.md",
score: 0.84,
snippet: "@@ -2,1\nsession canary",
},
]),
);
return child;
}
return createMockChild();
});
const { manager } = await createManager({ mode: "full" });
const inner = manager as unknown as {
collectionRoots: Map<string, { path: string }>;
resolveReadPath: (relPath: string) => string;
};
const sessionRoot = inner.collectionRoots.get("sessions-main");
expect(sessionRoot?.path).toBeTruthy();
const exportedSessionPath = path.join(sessionRoot!.path, "session-1.md");
const results = await manager.search("session canary", {
sessionKey: "agent:main:slack:dm:u123",
});
expect(results).toEqual([
{
path: "qmd/sessions-main/session-1.md",
startLine: 2,
endLine: 2,
score: 0.84,
snippet: "@@ -2,1\nsession canary",
source: "sessions",
},
]);
expect(inner.resolveReadPath(results[0]!.path)).toBe(exportedSessionPath);
const realLstat = fs.lstat;
const lstatSpy = vi.spyOn(fs, "lstat").mockImplementation(async (target, options) => {
if (typeof target === "string" && path.resolve(target) === exportedSessionPath) {
return {
isFile: () => true,
isSymbolicLink: () => false,
} as Awaited<ReturnType<typeof realLstat>>;
}
return await realLstat(target, options);
});
const realReadFile = fs.readFile;
const readSpy = vi.spyOn(fs, "readFile").mockImplementation(async (target, options) => {
if (typeof target === "string" && path.resolve(target) === exportedSessionPath) {
return "# Session session-1\n\nsession canary\n";
}
return await realReadFile(target, options as never);
});
try {
const readResult = await manager.readFile({ relPath: results[0]!.path });
expect(readResult).toEqual({
path: "qmd/sessions-main/session-1.md",
text: "# Session session-1\n\nsession canary\n",
});
} finally {
lstatSpy.mockRestore();
readSpy.mockRestore();
}
await manager.close();
});
it("preserves multi-collection qmd search hits when results only include file URIs", async () => {
cfg = {
...cfg,

View File

@@ -2192,15 +2192,22 @@ export class QmdMemoryManager implements MemorySearchManager {
relativeToWorkspace: string,
absPath: string,
): string {
const sanitized = collectionRelativePath.replace(/^\/+/, "");
const insideWorkspace = this.isInsideWorkspace(relativeToWorkspace);
if (insideWorkspace) {
const normalized = relativeToWorkspace.replace(/\\/g, "/");
if (!normalized) {
return path.basename(absPath);
}
// `qmd/<collection>/...` is a reserved virtual path namespace consumed by
// readFile(). If a real workspace file happens to live under `qmd/...`,
// return the explicit collection-scoped virtual path so search->read
// remains roundtrip-safe.
if (normalized === "qmd" || normalized.startsWith("qmd/")) {
return `qmd/${collection}/${sanitized}`;
}
return normalized;
}
const sanitized = collectionRelativePath.replace(/^\/+/, "");
return `qmd/${collection}/${sanitized}`;
}