fix(FN-000): scope qmd memory search to project
This commit is contained in:
@@ -441,6 +441,7 @@ export {
|
|||||||
memoryWorkspacePath,
|
memoryWorkspacePath,
|
||||||
memoryLongTermPath,
|
memoryLongTermPath,
|
||||||
memoryDreamsPath,
|
memoryDreamsPath,
|
||||||
|
qmdMemoryCollectionName,
|
||||||
dailyMemoryPath,
|
dailyMemoryPath,
|
||||||
getDefaultLongTermMemoryScaffold,
|
getDefaultLongTermMemoryScaffold,
|
||||||
getDefaultDailyMemoryScaffold,
|
getDefaultDailyMemoryScaffold,
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
memoryExists,
|
memoryExists,
|
||||||
MEMORY_BACKEND_SETTINGS_KEYS,
|
MEMORY_BACKEND_SETTINGS_KEYS,
|
||||||
DEFAULT_MEMORY_BACKEND,
|
DEFAULT_MEMORY_BACKEND,
|
||||||
|
buildQmdSearchArgs,
|
||||||
|
qmdMemoryCollectionName,
|
||||||
} from "./memory-backend.js";
|
} from "./memory-backend.js";
|
||||||
import type { MemoryBackend } from "./memory-backend.js";
|
import type { MemoryBackend } from "./memory-backend.js";
|
||||||
|
|
||||||
@@ -391,6 +393,35 @@ describe("memory-backend", () => {
|
|||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("qmd collection scoping", () => {
|
||||||
|
it("uses a stable project-scoped collection name", () => {
|
||||||
|
const collectionName = qmdMemoryCollectionName(tempDir);
|
||||||
|
|
||||||
|
expect(collectionName).toMatch(/^fusion-memory-kb-memory-backend-test-[a-z0-9_-]+-[a-f0-9]{12}$/);
|
||||||
|
expect(qmdMemoryCollectionName(tempDir)).toBe(collectionName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds qmd search args with the project collection filter", () => {
|
||||||
|
const args = buildQmdSearchArgs(tempDir, { query: "agent memory", limit: 7 });
|
||||||
|
const collectionName = qmdMemoryCollectionName(tempDir);
|
||||||
|
|
||||||
|
expect(args).toEqual([
|
||||||
|
"search",
|
||||||
|
"agent memory",
|
||||||
|
"--json",
|
||||||
|
"--collection",
|
||||||
|
collectionName,
|
||||||
|
"-n",
|
||||||
|
"7",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps qmd result limits", () => {
|
||||||
|
expect(buildQmdSearchArgs(tempDir, { query: "memory", limit: 999 })).toContain("20");
|
||||||
|
expect(buildQmdSearchArgs(tempDir, { query: "memory", limit: 0 })).toContain("1");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Backend Registry ──────────────────────────────────────────────
|
// ── Backend Registry ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import { readFile, writeFile, mkdir, access, constants, readdir, stat } from "node:fs/promises";
|
import { readFile, writeFile, mkdir, access, constants, readdir, stat } from "node:fs/promises";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
export const MEMORY_WORKSPACE_PATH = ".fusion/memory";
|
export const MEMORY_WORKSPACE_PATH = ".fusion/memory";
|
||||||
export const MEMORY_LONG_TERM_FILENAME = "MEMORY.md";
|
export const MEMORY_LONG_TERM_FILENAME = "MEMORY.md";
|
||||||
@@ -23,6 +24,7 @@ const DAILY_MEMORY_RE = /^\d{4}-\d{2}-\d{2}\.md$/;
|
|||||||
const MAX_MEMORY_SNIPPET_CHARS = 700;
|
const MAX_MEMORY_SNIPPET_CHARS = 700;
|
||||||
const DEFAULT_MEMORY_GET_LINES = 120;
|
const DEFAULT_MEMORY_GET_LINES = 120;
|
||||||
const MAX_MEMORY_GET_LINES = 400;
|
const MAX_MEMORY_GET_LINES = 400;
|
||||||
|
const QMD_COLLECTION_PREFIX = "fusion-memory";
|
||||||
|
|
||||||
// ── Type Definitions ────────────────────────────────────────────────
|
// ── Type Definitions ────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -459,6 +461,30 @@ export function memoryDreamsPath(rootDir: string): string {
|
|||||||
return join(memoryWorkspacePath(rootDir), MEMORY_DREAMS_FILENAME);
|
return join(memoryWorkspacePath(rootDir), MEMORY_DREAMS_FILENAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function qmdMemoryCollectionName(rootDir: string): string {
|
||||||
|
const absoluteRoot = resolve(rootDir);
|
||||||
|
const slug = basename(absoluteRoot)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9_-]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
|| "project";
|
||||||
|
const hash = createHash("sha1").update(absoluteRoot).digest("hex").slice(0, 12);
|
||||||
|
return `${QMD_COLLECTION_PREFIX}-${slug}-${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildQmdSearchArgs(rootDir: string, options: MemorySearchOptions): string[] {
|
||||||
|
const limit = Math.max(1, Math.min(options.limit ?? 5, 20));
|
||||||
|
return [
|
||||||
|
"search",
|
||||||
|
options.query,
|
||||||
|
"--json",
|
||||||
|
"--collection",
|
||||||
|
qmdMemoryCollectionName(rootDir),
|
||||||
|
"-n",
|
||||||
|
String(limit),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export function dailyMemoryPath(rootDir: string, date = new Date()): string {
|
export function dailyMemoryPath(rootDir: string, date = new Date()): string {
|
||||||
return join(memoryWorkspacePath(rootDir), `${date.toISOString().slice(0, 10)}.md`);
|
return join(memoryWorkspacePath(rootDir), `${date.toISOString().slice(0, 10)}.md`);
|
||||||
}
|
}
|
||||||
@@ -737,12 +763,13 @@ async function searchMemoryFiles(rootDir: string, options: MemorySearchOptions,
|
|||||||
|
|
||||||
async function searchWithQmd(rootDir: string, options: MemorySearchOptions): Promise<MemorySearchResult[]> {
|
async function searchWithQmd(rootDir: string, options: MemorySearchOptions): Promise<MemorySearchResult[]> {
|
||||||
const command = "qmd";
|
const command = "qmd";
|
||||||
const mode = "search";
|
const limit = Math.max(1, Math.min(options.limit ?? 5, 20));
|
||||||
const args = [mode, options.query, "--json"];
|
|
||||||
try {
|
try {
|
||||||
const { execFile } = await import("node:child_process");
|
const { execFile } = await import("node:child_process");
|
||||||
const { promisify } = await import("node:util");
|
const { promisify } = await import("node:util");
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
await ensureQmdProjectMemoryCollection(rootDir, execFileAsync);
|
||||||
|
const args = buildQmdSearchArgs(rootDir, options);
|
||||||
const { stdout } = await execFileAsync(command, args, {
|
const { stdout } = await execFileAsync(command, args, {
|
||||||
cwd: rootDir,
|
cwd: rootDir,
|
||||||
timeout: 4000,
|
timeout: 4000,
|
||||||
@@ -750,7 +777,7 @@ async function searchWithQmd(rootDir: string, options: MemorySearchOptions): Pro
|
|||||||
});
|
});
|
||||||
const parsed = JSON.parse(stdout);
|
const parsed = JSON.parse(stdout);
|
||||||
const rawResults = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.results) ? parsed.results : [];
|
const rawResults = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.results) ? parsed.results : [];
|
||||||
return rawResults.slice(0, Math.max(1, Math.min(options.limit ?? 5, 20))).map((result: Record<string, unknown>, index: number) => ({
|
return rawResults.slice(0, limit).map((result: Record<string, unknown>, index: number) => ({
|
||||||
path: String(result.path ?? result.file ?? `qmd/result-${index + 1}`),
|
path: String(result.path ?? result.file ?? `qmd/result-${index + 1}`),
|
||||||
lineStart: Number(result.lineStart ?? result.startLine ?? 1),
|
lineStart: Number(result.lineStart ?? result.startLine ?? 1),
|
||||||
lineEnd: Number(result.lineEnd ?? result.endLine ?? result.startLine ?? 1),
|
lineEnd: Number(result.lineEnd ?? result.endLine ?? result.startLine ?? 1),
|
||||||
@@ -763,6 +790,35 @@ async function searchWithQmd(rootDir: string, options: MemorySearchOptions): Pro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureQmdProjectMemoryCollection(
|
||||||
|
rootDir: string,
|
||||||
|
execFileAsync: (
|
||||||
|
file: string,
|
||||||
|
args: readonly string[],
|
||||||
|
options?: { cwd?: string; timeout?: number; maxBuffer?: number },
|
||||||
|
) => Promise<{ stdout: string; stderr: string }>,
|
||||||
|
): Promise<string> {
|
||||||
|
const collectionName = qmdMemoryCollectionName(rootDir);
|
||||||
|
const memoryDir = memoryWorkspacePath(rootDir);
|
||||||
|
await mkdir(memoryDir, { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execFileAsync("qmd", ["collection", "add", memoryDir, "--name", collectionName, "--mask", "**/*.md"], {
|
||||||
|
cwd: rootDir,
|
||||||
|
timeout: 4000,
|
||||||
|
maxBuffer: 512 * 1024,
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
const stderr = typeof err === "object" && err && "stderr" in err ? String((err as { stderr?: unknown }).stderr ?? "") : "";
|
||||||
|
if (!/already exists|exists/i.test(`${message}\n${stderr}`)) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return collectionName;
|
||||||
|
}
|
||||||
|
|
||||||
export async function isQmdAvailable(): Promise<boolean> {
|
export async function isQmdAvailable(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const { execFile } = await import("node:child_process");
|
const { execFile } = await import("node:child_process");
|
||||||
|
|||||||
Reference in New Issue
Block a user