mirror of https://github.com/openclaw/openclaw.git
fix(memory): preserve shared qmd collection names (#57628)
* fix(memory): preserve shared qmd collection names * fix(memory): canonicalize qmd path containment
This commit is contained in:
parent
85f3136cfc
commit
b7de04f23f
|
|
@ -316,6 +316,7 @@ Docs: https://docs.openclaw.ai
|
|||
- Runtime/install: lower the supported Node 22 floor to `22.14+` while continuing to recommend Node 24, so npm installs and self-updates do not strand Node 22.14 users on older releases.
|
||||
- CLI/update: preflight the target npm package `engines.node` before `openclaw update` runs a global package install, so outdated Node runtimes fail with a clear upgrade message instead of attempting an unsupported latest release.
|
||||
- Tests/security audit: isolate audit-test home and personal skill resolution so local `~/.agents/skills` installs no longer make maintainer prep runs fail nondeterministically. (#54473) thanks @huntharo
|
||||
- Memory/QMD: preserve explicit custom collection names for shared paths outside the agent workspace so `memory_search` stops appending `-<agentId>` to externally managed QMD collections. (#52539) Thanks @lobsrice and @vincentkoc.
|
||||
|
||||
## 2026.3.24-beta.1
|
||||
|
||||
|
|
|
|||
|
|
@ -1747,6 +1747,46 @@ describe("QmdMemoryManager", () => {
|
|||
await manager.close();
|
||||
});
|
||||
|
||||
it("uses explicit external custom collection names verbatim at query time", async () => {
|
||||
const sharedMirrorDir = path.join(tmpRoot, "shared-notion-mirror");
|
||||
await fs.mkdir(sharedMirrorDir);
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
|
||||
paths: [{ path: sharedMirrorDir, pattern: "**/*.md", name: "notion-mirror" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||
if (args[0] === "search") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
emitAndClose(child, "stdout", "[]");
|
||||
return child;
|
||||
}
|
||||
return createMockChild();
|
||||
});
|
||||
|
||||
const { manager, resolved } = await createManager();
|
||||
|
||||
await manager.search("test", { sessionKey: "agent:main:slack:dm:u123" });
|
||||
const maxResults = resolved.qmd?.limits.maxResults;
|
||||
if (!maxResults) {
|
||||
throw new Error("qmd maxResults missing");
|
||||
}
|
||||
const searchCalls = spawnMock.mock.calls
|
||||
.map((call: unknown[]) => call[1] as string[])
|
||||
.filter((args: string[]) => args[0] === "search");
|
||||
expect(searchCalls).toEqual([
|
||||
["search", "test", "--json", "-n", String(maxResults), "-c", "notion-mirror"],
|
||||
]);
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("runs qmd query per collection when query mode has multiple collection filters", async () => {
|
||||
cfg = {
|
||||
...cfg,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAgentWorkspaceDir } from "../../../../src/agents/agent-scope.js";
|
||||
|
|
@ -111,6 +113,66 @@ describe("resolveMemoryBackendConfig", () => {
|
|||
expect(devNames.has("workspace-dev")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves explicit custom collection names for paths outside the workspace", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: { workspace: "/workspace/root" },
|
||||
list: [
|
||||
{ id: "main", default: true, workspace: "/workspace/root" },
|
||||
{ id: "dev", workspace: "/workspace/dev" },
|
||||
],
|
||||
},
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: true,
|
||||
paths: [{ path: "/shared/notion-mirror", name: "notion-mirror", pattern: "**/*.md" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const mainResolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
|
||||
const devResolved = resolveMemoryBackendConfig({ cfg, agentId: "dev" });
|
||||
const mainNames = new Set(
|
||||
(mainResolved.qmd?.collections ?? []).map((collection) => collection.name),
|
||||
);
|
||||
const devNames = new Set(
|
||||
(devResolved.qmd?.collections ?? []).map((collection) => collection.name),
|
||||
);
|
||||
expect(mainNames.has("memory-dir-main")).toBe(true);
|
||||
expect(devNames.has("memory-dir-dev")).toBe(true);
|
||||
expect(mainNames.has("notion-mirror")).toBe(true);
|
||||
expect(devNames.has("notion-mirror")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps symlinked workspace paths agent-scoped when deciding custom collection names", async () => {
|
||||
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qmd-backend-config-"));
|
||||
const workspaceDir = path.join(tmpRoot, "workspace");
|
||||
const workspaceAliasDir = path.join(tmpRoot, "workspace-alias");
|
||||
try {
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.symlink(workspaceDir, workspaceAliasDir);
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: { workspace: workspaceDir },
|
||||
list: [{ id: "main", default: true, workspace: workspaceDir }],
|
||||
},
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
paths: [{ path: workspaceAliasDir, name: "workspace", pattern: "**/*.md" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
|
||||
const names = new Set((resolved.qmd?.collections ?? []).map((collection) => collection.name));
|
||||
expect(names.has("workspace-main")).toBe(true);
|
||||
expect(names.has("workspace")).toBe(false);
|
||||
} finally {
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves qmd update timeout overrides", () => {
|
||||
const cfg = {
|
||||
agents: { defaults: { workspace: "/tmp/memory-test" } },
|
||||
|
|
@ -283,6 +345,25 @@ describe("memorySearch.extraPaths integration", () => {
|
|||
expect(paths).toContain(resolveComparablePath("/agent-only"));
|
||||
});
|
||||
|
||||
it("keeps unnamed extra paths agent-scoped even when they resolve outside the workspace", () => {
|
||||
const cfg = {
|
||||
memory: { backend: "qmd" },
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: "/workspace/root",
|
||||
memorySearch: {
|
||||
extraPaths: ["/shared/path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const result = resolveMemoryBackendConfig({ cfg, agentId: "my-agent" });
|
||||
const customCollections = (result.qmd?.collections ?? []).filter(
|
||||
(collection) => collection.kind === "custom",
|
||||
);
|
||||
expect(customCollections.map((collection) => collection.name)).toContain("custom-1-my-agent");
|
||||
});
|
||||
|
||||
it("matches per-agent memorySearch.extraPaths using normalized agent ids", () => {
|
||||
const cfg = {
|
||||
memory: { backend: "qmd" },
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveAgentWorkspaceDir } from "../../../../src/agents/agent-scope.js";
|
||||
import { parseDurationMs } from "../../../../src/cli/parse-duration.js";
|
||||
|
|
@ -115,6 +116,23 @@ function scopeCollectionBase(base: string, agentId: string): string {
|
|||
return `${base}-${sanitizeName(agentId)}`;
|
||||
}
|
||||
|
||||
function canonicalizePathForContainment(rawPath: string): string {
|
||||
const resolved = path.resolve(rawPath);
|
||||
try {
|
||||
return path.normalize(fs.realpathSync.native(resolved));
|
||||
} catch {
|
||||
return path.normalize(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
function isPathInsideRoot(candidatePath: string, rootPath: string): boolean {
|
||||
const relative = path.relative(
|
||||
canonicalizePathForContainment(rootPath),
|
||||
canonicalizePathForContainment(candidatePath),
|
||||
);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function ensureUniqueName(base: string, existing: Set<string>): string {
|
||||
let name = sanitizeName(base);
|
||||
if (!existing.has(name)) {
|
||||
|
|
@ -252,7 +270,11 @@ function resolveCustomPaths(
|
|||
return;
|
||||
}
|
||||
seenRoots.add(dedupeKey);
|
||||
const baseName = scopeCollectionBase(entry.name?.trim() || `custom-${index + 1}`, agentId);
|
||||
const explicitName = entry.name?.trim();
|
||||
const baseName =
|
||||
explicitName && !isPathInsideRoot(resolved, workspaceDir)
|
||||
? explicitName
|
||||
: scopeCollectionBase(explicitName || `custom-${index + 1}`, agentId);
|
||||
const name = ensureUniqueName(baseName, existing);
|
||||
collections.push({
|
||||
name,
|
||||
|
|
|
|||
Loading…
Reference in New Issue