mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-24 07:01:49 +00:00
fix(memory): keep qmd session paths roundtrip-safe (#57560)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user