mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 15:30:39 +00:00
fix(memory): handle qmd search results without docid
This commit is contained in:
@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Memory/QMD search result decoding: accept `qmd search` hits that only include `file` URIs (for example `qmd://collection/path.md`) without `docid`, resolve them through managed collection roots, and keep multi-collection results keyed by file fallback so valid QMD hits no longer collapse to empty `memory_search` output. (#28181) Thanks @0x76696265.
|
||||
- Memory/QMD collection-name conflict recovery: when `qmd collection add` fails because another collection already occupies the same `path + pattern`, detect the conflicting collection from `collection list`, remove it, and retry add so agent-scoped managed collections are created deterministically instead of being silently skipped; also add warning-only fallback when qmd metadata is unavailable to avoid destructive guesses. (#25496) Thanks @Ramsbaby.
|
||||
- Slack/app_mention race dedupe: when `app_mention` dispatch wins while same-`ts` `message` prepare is still in-flight, suppress the later message dispatch so near-simultaneous Slack deliveries do not produce duplicate replies; keep single-retry behavior and add regression coverage for both dropped and successful message-prepare outcomes. (#37033) Thanks @Takhoffman.
|
||||
- Gateway/chat streaming tool-boundary text retention: merge assistant delta segments into per-run chat buffers so pre-tool text is preserved in live chat deltas/finals when providers emit post-tool assistant segments as non-prefix snapshots. (#36957) Thanks @Datyedyeguy.
|
||||
|
||||
@@ -2385,6 +2385,132 @@ describe("QmdMemoryManager", () => {
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("resolves search hits when qmd returns qmd:// file URIs without docid", async () => {
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
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://workspace-main/notes/welcome.md",
|
||||
score: 0.71,
|
||||
snippet: "@@ -4,1\ntoken unlock",
|
||||
},
|
||||
]),
|
||||
);
|
||||
return child;
|
||||
}
|
||||
return createMockChild();
|
||||
});
|
||||
|
||||
const { manager } = await createManager();
|
||||
|
||||
const results = await manager.search("token unlock", {
|
||||
sessionKey: "agent:main:slack:dm:u123",
|
||||
});
|
||||
expect(results).toEqual([
|
||||
{
|
||||
path: "notes/welcome.md",
|
||||
startLine: 4,
|
||||
endLine: 4,
|
||||
score: 0.71,
|
||||
snippet: "@@ -4,1\ntoken unlock",
|
||||
source: "memory",
|
||||
},
|
||||
]);
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("preserves multi-collection qmd search hits when results only include file URIs", async () => {
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
|
||||
paths: [
|
||||
{ path: workspaceDir, pattern: "**/*.md", name: "workspace" },
|
||||
{ path: path.join(workspaceDir, "notes"), pattern: "**/*.md", name: "notes" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||
if (args[0] === "search" && args.includes("workspace-main")) {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
emitAndClose(
|
||||
child,
|
||||
"stdout",
|
||||
JSON.stringify([
|
||||
{
|
||||
file: "qmd://workspace-main/memory/facts.md",
|
||||
score: 0.8,
|
||||
snippet: "@@ -2,1\nworkspace fact",
|
||||
},
|
||||
]),
|
||||
);
|
||||
return child;
|
||||
}
|
||||
if (args[0] === "search" && args.includes("notes-main")) {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
emitAndClose(
|
||||
child,
|
||||
"stdout",
|
||||
JSON.stringify([
|
||||
{
|
||||
file: "qmd://notes-main/guide.md",
|
||||
score: 0.7,
|
||||
snippet: "@@ -1,1\nnotes guide",
|
||||
},
|
||||
]),
|
||||
);
|
||||
return child;
|
||||
}
|
||||
return createMockChild();
|
||||
});
|
||||
|
||||
const { manager } = await createManager();
|
||||
|
||||
const results = await manager.search("fact", {
|
||||
sessionKey: "agent:main:slack:dm:u123",
|
||||
});
|
||||
expect(results).toEqual([
|
||||
{
|
||||
path: "memory/facts.md",
|
||||
startLine: 2,
|
||||
endLine: 2,
|
||||
score: 0.8,
|
||||
snippet: "@@ -2,1\nworkspace fact",
|
||||
source: "memory",
|
||||
},
|
||||
{
|
||||
path: "notes/guide.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
score: 0.7,
|
||||
snippet: "@@ -1,1\nnotes guide",
|
||||
source: "memory",
|
||||
},
|
||||
]);
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("errors when qmd output exceeds command output safety cap", async () => {
|
||||
const noisyPayload = "x".repeat(240_000);
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||
|
||||
@@ -873,10 +873,11 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
}
|
||||
const results: MemorySearchResult[] = [];
|
||||
for (const entry of parsed) {
|
||||
const doc = await this.resolveDocLocation(entry.docid, {
|
||||
const docHints = this.normalizeDocHints({
|
||||
preferredCollection: entry.collection,
|
||||
preferredFile: entry.file,
|
||||
});
|
||||
const doc = await this.resolveDocLocation(entry.docid, docHints);
|
||||
if (!doc) {
|
||||
continue;
|
||||
}
|
||||
@@ -1614,14 +1615,15 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
docid?: string,
|
||||
hints?: { preferredCollection?: string; preferredFile?: string },
|
||||
): Promise<{ rel: string; abs: string; source: MemorySource } | null> {
|
||||
const normalizedHints = this.normalizeDocHints(hints);
|
||||
if (!docid) {
|
||||
return null;
|
||||
return this.resolveDocLocationFromHints(normalizedHints);
|
||||
}
|
||||
const normalized = docid.startsWith("#") ? docid.slice(1) : docid;
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const cacheKey = `${hints?.preferredCollection ?? "*"}:${normalized}`;
|
||||
const cacheKey = `${normalizedHints.preferredCollection ?? "*"}:${normalized}`;
|
||||
const cached = this.docPathCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
@@ -1647,7 +1649,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const location = this.pickDocLocation(rows, hints);
|
||||
const location = this.pickDocLocation(rows, normalizedHints);
|
||||
if (!location) {
|
||||
return null;
|
||||
}
|
||||
@@ -1655,6 +1657,86 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
return location;
|
||||
}
|
||||
|
||||
private resolveDocLocationFromHints(hints: {
|
||||
preferredCollection?: string;
|
||||
preferredFile?: string;
|
||||
}): { rel: string; abs: string; source: MemorySource } | null {
|
||||
if (!hints.preferredCollection || !hints.preferredFile) {
|
||||
return null;
|
||||
}
|
||||
const collectionRelativePath = this.toCollectionRelativePath(
|
||||
hints.preferredCollection,
|
||||
hints.preferredFile,
|
||||
);
|
||||
if (!collectionRelativePath) {
|
||||
return null;
|
||||
}
|
||||
return this.toDocLocation(hints.preferredCollection, collectionRelativePath);
|
||||
}
|
||||
|
||||
private normalizeDocHints(hints?: { preferredCollection?: string; preferredFile?: string }): {
|
||||
preferredCollection?: string;
|
||||
preferredFile?: string;
|
||||
} {
|
||||
const preferredCollection = hints?.preferredCollection?.trim();
|
||||
const preferredFile = hints?.preferredFile?.trim();
|
||||
if (!preferredFile) {
|
||||
return preferredCollection ? { preferredCollection } : {};
|
||||
}
|
||||
|
||||
const parsedQmdFile = this.parseQmdFileUri(preferredFile);
|
||||
return {
|
||||
preferredCollection: parsedQmdFile?.collection ?? preferredCollection,
|
||||
preferredFile: parsedQmdFile?.collectionRelativePath ?? preferredFile,
|
||||
};
|
||||
}
|
||||
|
||||
private parseQmdFileUri(fileRef: string): {
|
||||
collection?: string;
|
||||
collectionRelativePath?: string;
|
||||
} | null {
|
||||
if (!fileRef.toLowerCase().startsWith("qmd://")) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(fileRef);
|
||||
const collection = decodeURIComponent(parsed.hostname).trim();
|
||||
const pathname = decodeURIComponent(parsed.pathname).replace(/^\/+/, "").trim();
|
||||
if (!collection && !pathname) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
collection: collection || undefined,
|
||||
collectionRelativePath: pathname || undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private toCollectionRelativePath(collection: string, filePath: string): string | null {
|
||||
const root = this.collectionRoots.get(collection);
|
||||
if (!root) {
|
||||
return null;
|
||||
}
|
||||
const trimmedFilePath = filePath.trim();
|
||||
if (!trimmedFilePath) {
|
||||
return null;
|
||||
}
|
||||
const normalizedInput = path.normalize(trimmedFilePath);
|
||||
const absolutePath = path.isAbsolute(normalizedInput)
|
||||
? normalizedInput
|
||||
: path.resolve(root.path, normalizedInput);
|
||||
if (!this.isWithinRoot(root.path, absolutePath)) {
|
||||
return null;
|
||||
}
|
||||
const relative = path.relative(root.path, absolutePath);
|
||||
if (!relative || relative === ".") {
|
||||
return null;
|
||||
}
|
||||
return relative.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
private pickDocLocation(
|
||||
rows: Array<{ collection: string; path: string }>,
|
||||
hints?: { preferredCollection?: string; preferredFile?: string },
|
||||
@@ -1982,37 +2064,64 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
log.debug(
|
||||
`qmd ${command} multi-collection workaround active (${collectionNames.length} collections)`,
|
||||
);
|
||||
const bestByDocId = new Map<string, QmdQueryResult>();
|
||||
const bestByResultKey = new Map<string, QmdQueryResult>();
|
||||
for (const collectionName of collectionNames) {
|
||||
const args = this.buildSearchArgs(command, query, limit);
|
||||
args.push("-c", collectionName);
|
||||
const result = await this.runQmd(args, { timeoutMs: this.qmd.limits.timeoutMs });
|
||||
const parsed = parseQmdQueryJson(result.stdout, result.stderr);
|
||||
for (const entry of parsed) {
|
||||
const normalizedHints = this.normalizeDocHints({
|
||||
preferredCollection: entry.collection ?? collectionName,
|
||||
preferredFile: entry.file,
|
||||
});
|
||||
const normalizedDocId =
|
||||
typeof entry.docid === "string" && entry.docid.trim().length > 0
|
||||
? entry.docid
|
||||
: undefined;
|
||||
if (!normalizedDocId) {
|
||||
continue;
|
||||
}
|
||||
const withCollection = {
|
||||
...entry,
|
||||
docid: normalizedDocId,
|
||||
collection: entry.collection ?? collectionName,
|
||||
collection: normalizedHints.preferredCollection ?? entry.collection ?? collectionName,
|
||||
file: normalizedHints.preferredFile ?? entry.file,
|
||||
} satisfies QmdQueryResult;
|
||||
const prev = bestByDocId.get(normalizedDocId);
|
||||
const resultKey = this.buildQmdResultKey(withCollection);
|
||||
if (!resultKey) {
|
||||
continue;
|
||||
}
|
||||
const prev = bestByResultKey.get(resultKey);
|
||||
const prevScore = typeof prev?.score === "number" ? prev.score : Number.NEGATIVE_INFINITY;
|
||||
const nextScore =
|
||||
typeof withCollection.score === "number"
|
||||
? withCollection.score
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
if (!prev || nextScore > prevScore) {
|
||||
bestByDocId.set(normalizedDocId, withCollection);
|
||||
bestByResultKey.set(resultKey, withCollection);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...bestByDocId.values()].toSorted((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
||||
return [...bestByResultKey.values()].toSorted((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
||||
}
|
||||
|
||||
private buildQmdResultKey(entry: QmdQueryResult): string | null {
|
||||
if (typeof entry.docid === "string" && entry.docid.trim().length > 0) {
|
||||
return `docid:${entry.docid}`;
|
||||
}
|
||||
const hints = this.normalizeDocHints({
|
||||
preferredCollection: entry.collection,
|
||||
preferredFile: entry.file,
|
||||
});
|
||||
if (!hints.preferredCollection || !hints.preferredFile) {
|
||||
return null;
|
||||
}
|
||||
const collectionRelativePath = this.toCollectionRelativePath(
|
||||
hints.preferredCollection,
|
||||
hints.preferredFile,
|
||||
);
|
||||
if (!collectionRelativePath) {
|
||||
return null;
|
||||
}
|
||||
return `file:${hints.preferredCollection}:${collectionRelativePath}`;
|
||||
}
|
||||
|
||||
private async runMcporterAcrossCollections(params: {
|
||||
|
||||
Reference in New Issue
Block a user