mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-26 07:57:40 +00:00
fix(memory): use explicit qmd snippet line metadata (#58181)
* fix(memory): preserve qmd snippet line metadata * Memory/QMD: preserve snippet span with partial line metadata
This commit is contained in:
@@ -2080,6 +2080,153 @@ describe("QmdMemoryManager", () => {
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("prefers mcporter start and end lines over snippet header offsets", async () => {
|
||||
const expectedDocId = "line-123";
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
searchMode: "query",
|
||||
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
|
||||
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
||||
mcporter: { enabled: true, serverName: "qmd", startDaemon: false },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
spawnMock.mockImplementation((cmd: string, args: string[]) => {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
if (isMcporterCommand(cmd) && args[0] === "call") {
|
||||
expect(args[1]).toBe("qmd.query");
|
||||
emitAndClose(
|
||||
child,
|
||||
"stdout",
|
||||
JSON.stringify({
|
||||
results: [
|
||||
{
|
||||
docid: expectedDocId,
|
||||
score: 0.91,
|
||||
collection: "workspace-main",
|
||||
start_line: 8,
|
||||
end_line: 10,
|
||||
snippet: "@@ -20,3\nline one\nline two\nline three",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
return child;
|
||||
}
|
||||
emitAndClose(child, "stdout", "[]");
|
||||
return child;
|
||||
});
|
||||
|
||||
const { manager } = await createManager();
|
||||
const inner = manager as unknown as {
|
||||
db: { prepare: (query: string) => { all: (arg: unknown) => unknown }; close: () => void };
|
||||
};
|
||||
inner.db = {
|
||||
prepare: (_query: string) => ({
|
||||
all: (arg: unknown) => {
|
||||
if (typeof arg === "string" && arg.startsWith(expectedDocId)) {
|
||||
return [{ collection: "workspace-main", path: "notes/welcome.md" }];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
}),
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
await expect(
|
||||
manager.search("line one", { sessionKey: "agent:main:slack:dm:u123" }),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
path: "notes/welcome.md",
|
||||
startLine: 8,
|
||||
endLine: 10,
|
||||
score: 0.91,
|
||||
snippet: "@@ -20,3\nline one\nline two\nline three",
|
||||
source: "memory",
|
||||
},
|
||||
]);
|
||||
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("uses snippet header width when mcporter only returns a start line", async () => {
|
||||
const expectedDocId = "line-456";
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
searchMode: "query",
|
||||
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
|
||||
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
||||
mcporter: { enabled: true, serverName: "qmd", startDaemon: false },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
spawnMock.mockImplementation((cmd: string, args: string[]) => {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
if (isMcporterCommand(cmd) && args[0] === "call") {
|
||||
expect(args[1]).toBe("qmd.query");
|
||||
emitAndClose(
|
||||
child,
|
||||
"stdout",
|
||||
JSON.stringify({
|
||||
results: [
|
||||
{
|
||||
docid: expectedDocId,
|
||||
score: 0.73,
|
||||
collection: "workspace-main",
|
||||
start_line: 8,
|
||||
snippet: "@@ -20,3\nline one\nline two\nline three",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
return child;
|
||||
}
|
||||
emitAndClose(child, "stdout", "[]");
|
||||
return child;
|
||||
});
|
||||
|
||||
const { manager } = await createManager();
|
||||
const inner = manager as unknown as {
|
||||
db: { prepare: (query: string) => { all: (arg: unknown) => unknown }; close: () => void };
|
||||
};
|
||||
inner.db = {
|
||||
prepare: (_query: string) => ({
|
||||
all: (arg: unknown) => {
|
||||
if (typeof arg === "string" && arg.startsWith(expectedDocId)) {
|
||||
return [{ collection: "workspace-main", path: "notes/welcome.md" }];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
}),
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
await expect(
|
||||
manager.search("line one", { sessionKey: "agent:main:slack:dm:u123" }),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
path: "notes/welcome.md",
|
||||
startLine: 8,
|
||||
endLine: 10,
|
||||
score: 0.73,
|
||||
snippet: "@@ -20,3\nline one\nline two\nline three",
|
||||
source: "memory",
|
||||
},
|
||||
]);
|
||||
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it('uses unified v2 args when the explicit mcporter search tool override is "query"', async () => {
|
||||
cfg = {
|
||||
...cfg,
|
||||
|
||||
@@ -983,7 +983,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
continue;
|
||||
}
|
||||
const snippet = entry.snippet?.slice(0, this.qmd.limits.maxSnippetChars) ?? "";
|
||||
const lines = this.extractSnippetLines(snippet);
|
||||
const lines = this.resolveSnippetLines(entry, snippet);
|
||||
const score = typeof entry.score === "number" ? entry.score : 0;
|
||||
const minScore = opts?.minScore ?? 0;
|
||||
if (score < minScore) {
|
||||
@@ -1681,7 +1681,16 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
const scoreRaw = item.score;
|
||||
const score = typeof scoreRaw === "number" ? scoreRaw : Number(scoreRaw);
|
||||
const snippet = typeof item.snippet === "string" ? item.snippet : "";
|
||||
out.push({ docid, score: Number.isFinite(score) ? score : 0, snippet });
|
||||
out.push({
|
||||
docid,
|
||||
score: Number.isFinite(score) ? score : 0,
|
||||
snippet,
|
||||
collection: typeof item.collection === "string" ? item.collection : undefined,
|
||||
file: typeof item.file === "string" ? item.file : undefined,
|
||||
body: typeof item.body === "string" ? item.body : undefined,
|
||||
startLine: this.normalizeSnippetLine(item.start_line ?? item.startLine),
|
||||
endLine: this.normalizeSnippetLine(item.end_line ?? item.endLine),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -2099,18 +2108,69 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
}
|
||||
|
||||
private extractSnippetLines(snippet: string): { startLine: number; endLine: number } {
|
||||
const match = SNIPPET_HEADER_RE.exec(snippet);
|
||||
if (match) {
|
||||
const start = Number(match[1]);
|
||||
const count = Number(match[2]);
|
||||
if (Number.isFinite(start) && Number.isFinite(count)) {
|
||||
return { startLine: start, endLine: start + count - 1 };
|
||||
}
|
||||
const headerLines = this.parseSnippetHeaderLines(snippet);
|
||||
if (headerLines) {
|
||||
return headerLines;
|
||||
}
|
||||
const lines = snippet.split("\n").length;
|
||||
return { startLine: 1, endLine: lines };
|
||||
}
|
||||
|
||||
private resolveSnippetLines(
|
||||
entry: QmdQueryResult,
|
||||
snippet: string,
|
||||
): { startLine: number; endLine: number } {
|
||||
const explicitStart = this.normalizeSnippetLine(entry.startLine);
|
||||
const explicitEnd = this.normalizeSnippetLine(entry.endLine);
|
||||
const headerLines = this.parseSnippetHeaderLines(snippet);
|
||||
if (explicitStart !== undefined && explicitEnd !== undefined) {
|
||||
return explicitStart <= explicitEnd
|
||||
? { startLine: explicitStart, endLine: explicitEnd }
|
||||
: { startLine: explicitEnd, endLine: explicitStart };
|
||||
}
|
||||
if (explicitStart !== undefined) {
|
||||
if (headerLines) {
|
||||
const width = headerLines.endLine - headerLines.startLine;
|
||||
return {
|
||||
startLine: explicitStart,
|
||||
endLine: explicitStart + Math.max(0, width),
|
||||
};
|
||||
}
|
||||
return { startLine: explicitStart, endLine: explicitStart };
|
||||
}
|
||||
if (explicitEnd !== undefined) {
|
||||
if (headerLines) {
|
||||
const width = headerLines.endLine - headerLines.startLine;
|
||||
return {
|
||||
startLine: Math.max(1, explicitEnd - Math.max(0, width)),
|
||||
endLine: explicitEnd,
|
||||
};
|
||||
}
|
||||
return { startLine: explicitEnd, endLine: explicitEnd };
|
||||
}
|
||||
if (headerLines) {
|
||||
return headerLines;
|
||||
}
|
||||
return { startLine: 1, endLine: snippet.split("\n").length };
|
||||
}
|
||||
|
||||
private normalizeSnippetLine(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
private parseSnippetHeaderLines(snippet: string): { startLine: number; endLine: number } | null {
|
||||
const match = SNIPPET_HEADER_RE.exec(snippet);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const start = Number(match[1]);
|
||||
const count = Number(match[2]);
|
||||
if (Number.isFinite(start) && Number.isFinite(count)) {
|
||||
return { startLine: start, endLine: start + count - 1 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private readCounts(): {
|
||||
totalDocuments: number;
|
||||
sourceCounts: Array<{ source: MemorySource; files: number; chunks: number }>;
|
||||
|
||||
Reference in New Issue
Block a user