fix(memory-qmd): write XDG index.yml + legacy compat

This commit is contained in:
Vignesh Natarajan
2026-01-28 00:12:18 -08:00
committed by vignesh07
parent bdf692ae54
commit e274914b86
5 changed files with 53 additions and 4 deletions

View File

@@ -268,7 +268,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
| Awaited<ReturnType<typeof manager.probeEmbeddingAvailability>> | Awaited<ReturnType<typeof manager.probeEmbeddingAvailability>>
| undefined; | undefined;
let indexError: string | undefined; let indexError: string | undefined;
const syncFn = manager.sync; const syncFn = manager.sync ? manager.sync.bind(manager) : undefined;
if (deep) { if (deep) {
await withProgress({ label: "Checking memory…", total: 2 }, async (progress) => { await withProgress({ label: "Checking memory…", total: 2 }, async (progress) => {
progress.setLabel("Probing vector…"); progress.setLabel("Probing vector…");
@@ -517,7 +517,7 @@ export function registerMemoryCli(program: Command) {
}, },
run: async (manager) => { run: async (manager) => {
try { try {
const syncFn = manager.sync; const syncFn = manager.sync ? manager.sync.bind(manager) : undefined;
if (opts.verbose) { if (opts.verbose) {
const status = manager.status(); const status = manager.status();
const rich = isRich(); const rich = isRich();

View File

@@ -6,7 +6,7 @@ import { EventEmitter } from "node:events";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("node:child_process", () => { vi.mock("node:child_process", () => {
const spawn = vi.fn((cmd: string, _args: string[]) => { const spawn = vi.fn((_cmd: string, _args: string[]) => {
const stdout = new EventEmitter(); const stdout = new EventEmitter();
const stderr = new EventEmitter(); const stderr = new EventEmitter();
const child = new EventEmitter() as { const child = new EventEmitter() as {

View File

@@ -74,6 +74,8 @@ export class QmdMemoryManager implements MemorySearchManager {
private readonly xdgCacheHome: string; private readonly xdgCacheHome: string;
private readonly collectionsFile: string; private readonly collectionsFile: string;
private readonly indexPath: string; private readonly indexPath: string;
private readonly legacyCollectionsFile: string;
private readonly legacyIndexPath: string;
private readonly env: NodeJS.ProcessEnv; private readonly env: NodeJS.ProcessEnv;
private readonly collectionRoots = new Map<string, CollectionRoot>(); private readonly collectionRoots = new Map<string, CollectionRoot>();
private readonly sources = new Set<MemorySource>(); private readonly sources = new Set<MemorySource>();
@@ -107,6 +109,12 @@ export class QmdMemoryManager implements MemorySearchManager {
this.xdgCacheHome = path.join(this.qmdDir, "xdg-cache"); this.xdgCacheHome = path.join(this.qmdDir, "xdg-cache");
this.collectionsFile = path.join(this.xdgConfigHome, "qmd", "index.yml"); this.collectionsFile = path.join(this.xdgConfigHome, "qmd", "index.yml");
this.indexPath = path.join(this.xdgCacheHome, "qmd", "index.sqlite"); this.indexPath = path.join(this.xdgCacheHome, "qmd", "index.sqlite");
// Legacy locations (older builds wrote here). Keep them in sync via symlinks
// so upgrades don't strand an empty index.
this.legacyCollectionsFile = path.join(this.qmdDir, "config", "index.yml");
this.legacyIndexPath = path.join(this.qmdDir, "cache", "index.sqlite");
this.env = { this.env = {
...process.env, ...process.env,
XDG_CONFIG_HOME: this.xdgConfigHome, XDG_CONFIG_HOME: this.xdgConfigHome,
@@ -141,8 +149,13 @@ export class QmdMemoryManager implements MemorySearchManager {
await fs.mkdir(path.dirname(this.collectionsFile), { recursive: true }); await fs.mkdir(path.dirname(this.collectionsFile), { recursive: true });
await fs.mkdir(path.dirname(this.indexPath), { recursive: true }); await fs.mkdir(path.dirname(this.indexPath), { recursive: true });
// Legacy dirs
await fs.mkdir(path.dirname(this.legacyCollectionsFile), { recursive: true });
await fs.mkdir(path.dirname(this.legacyIndexPath), { recursive: true });
this.bootstrapCollections(); this.bootstrapCollections();
await this.writeCollectionsConfig(); await this.writeCollectionsConfig();
await this.ensureLegacyCompatSymlinks();
if (this.qmd.update.onBoot) { if (this.qmd.update.onBoot) {
await this.runUpdate("boot", true); await this.runUpdate("boot", true);
@@ -176,6 +189,42 @@ export class QmdMemoryManager implements MemorySearchManager {
} }
const yaml = YAML.stringify({ collections }, { indent: 2, lineWidth: 0 }); const yaml = YAML.stringify({ collections }, { indent: 2, lineWidth: 0 });
await fs.writeFile(this.collectionsFile, yaml, "utf-8"); await fs.writeFile(this.collectionsFile, yaml, "utf-8");
// Also write legacy path so older qmd homes remain usable.
await fs.writeFile(this.legacyCollectionsFile, yaml, "utf-8");
}
private async ensureLegacyCompatSymlinks(): Promise<void> {
// Best-effort: keep legacy locations pointing at the XDG locations.
// This helps when users have old state dirs on disk.
try {
await fs.rm(this.legacyCollectionsFile, { force: true });
} catch {}
try {
await fs.symlink(this.collectionsFile, this.legacyCollectionsFile);
} catch {}
try {
// If a legacy index already exists (from an older version), prefer it by
// linking the XDG path to the legacy DB.
const legacyExists = await fs
.stat(this.legacyIndexPath)
.then(() => true)
.catch(() => false);
const xdgExists = await fs
.stat(this.indexPath)
.then(() => true)
.catch(() => false);
if (legacyExists && !xdgExists) {
await fs.symlink(this.legacyIndexPath, this.indexPath);
} else if (!legacyExists && xdgExists) {
// nothing to do
} else if (!legacyExists && !xdgExists) {
// Create an empty file so the path exists for read-only opens later.
await fs.writeFile(this.indexPath, "");
}
} catch {}
} }
async search( async search(

View File

@@ -59,6 +59,7 @@ describe("getMemorySearchManager caching", () => {
const second = await getMemorySearchManager({ cfg, agentId: "main" }); const second = await getMemorySearchManager({ cfg, agentId: "main" });
expect(first.manager).toBe(second.manager); expect(first.manager).toBe(second.manager);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(QmdMemoryManager.create).toHaveBeenCalledTimes(1); expect(QmdMemoryManager.create).toHaveBeenCalledTimes(1);
}); });
}); });

View File

@@ -2,7 +2,6 @@ import { createSubsystemLogger } from "../logging/subsystem.js";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import { resolveMemoryBackendConfig } from "./backend-config.js"; import { resolveMemoryBackendConfig } from "./backend-config.js";
import type { ResolvedQmdConfig } from "./backend-config.js"; import type { ResolvedQmdConfig } from "./backend-config.js";
import type { MemoryIndexManager } from "./manager.js";
import type { import type {
MemoryEmbeddingProbeResult, MemoryEmbeddingProbeResult,
MemorySearchManager, MemorySearchManager,