fix(memory-qmd): create collections via qmd CLI (no YAML)

main
Vignesh Natarajan 2026-01-28 00:33:17 -08:00 committed by Vignesh
parent dd8373a424
commit 564fe6f089
2 changed files with 29 additions and 67 deletions

View File

@ -78,17 +78,19 @@ describe("QmdMemoryManager", () => {
expect(manager).toBeTruthy(); expect(manager).toBeTruthy();
if (!manager) throw new Error("manager missing"); if (!manager) throw new Error("manager missing");
const baselineCalls = spawnMock.mock.calls.length;
await manager.sync({ reason: "manual" }); await manager.sync({ reason: "manual" });
expect(spawnMock.mock.calls.length).toBe(2); expect(spawnMock.mock.calls.length).toBe(baselineCalls + 2);
await manager.sync({ reason: "manual-again" }); await manager.sync({ reason: "manual-again" });
expect(spawnMock.mock.calls.length).toBe(2); expect(spawnMock.mock.calls.length).toBe(baselineCalls + 2);
(manager as unknown as { lastUpdateAt: number | null }).lastUpdateAt = (manager as unknown as { lastUpdateAt: number | null }).lastUpdateAt =
Date.now() - (resolved.qmd?.update.debounceMs ?? 0) - 10; Date.now() - (resolved.qmd?.update.debounceMs ?? 0) - 10;
await manager.sync({ reason: "after-wait" }); await manager.sync({ reason: "after-wait" });
expect(spawnMock.mock.calls.length).toBe(4); expect(spawnMock.mock.calls.length).toBe(baselineCalls + 4);
await manager.close(); await manager.close();
}); });

View File

@ -3,8 +3,6 @@ import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import YAML from "yaml";
import type { MoltbotConfig } from "../config/config.js"; import type { MoltbotConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js"; import { resolveStateDir } from "../config/paths.js";
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
@ -72,10 +70,7 @@ export class QmdMemoryManager implements MemorySearchManager {
private readonly qmdDir: string; private readonly qmdDir: string;
private readonly xdgConfigHome: string; private readonly xdgConfigHome: string;
private readonly xdgCacheHome: string; private readonly xdgCacheHome: 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>();
@ -102,19 +97,14 @@ export class QmdMemoryManager implements MemorySearchManager {
this.stateDir = resolveStateDir(process.env, os.homedir); this.stateDir = resolveStateDir(process.env, os.homedir);
this.agentStateDir = path.join(this.stateDir, "agents", this.agentId); this.agentStateDir = path.join(this.stateDir, "agents", this.agentId);
this.qmdDir = path.join(this.agentStateDir, "qmd"); this.qmdDir = path.join(this.agentStateDir, "qmd");
// QMD respects XDG base dirs: // QMD uses XDG base dirs for its internal state.
// - config: $XDG_CONFIG_HOME/qmd/index.yml // Collections are managed via `qmd collection add` and stored inside the index DB.
// - config: $XDG_CONFIG_HOME (contexts, etc.)
// - cache: $XDG_CACHE_HOME/qmd/index.sqlite // - cache: $XDG_CACHE_HOME/qmd/index.sqlite
this.xdgConfigHome = path.join(this.qmdDir, "xdg-config"); this.xdgConfigHome = path.join(this.qmdDir, "xdg-config");
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.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,
@ -146,16 +136,10 @@ export class QmdMemoryManager implements MemorySearchManager {
private async initialize(): Promise<void> { private async initialize(): Promise<void> {
await fs.mkdir(this.xdgConfigHome, { recursive: true }); await fs.mkdir(this.xdgConfigHome, { recursive: true });
await fs.mkdir(this.xdgCacheHome, { recursive: true }); await fs.mkdir(this.xdgCacheHome, { 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.ensureCollections();
await this.ensureLegacyCompatSymlinks();
if (this.qmd.update.onBoot) { if (this.qmd.update.onBoot) {
await this.runUpdate("boot", true); await this.runUpdate("boot", true);
@ -179,52 +163,28 @@ export class QmdMemoryManager implements MemorySearchManager {
} }
} }
private async writeCollectionsConfig(): Promise<void> { private async ensureCollections(): Promise<void> {
const collections: Record<string, { path: string; pattern: string }> = {}; // QMD collections are persisted inside the index database and must be created
// via the CLI. The YAML file format is not supported by the QMD builds we
// target, so we ensure collections exist by running `qmd collection add`.
for (const collection of this.qmd.collections) { for (const collection of this.qmd.collections) {
collections[collection.name] = { try {
path: collection.path, await this.runQmd([
pattern: collection.pattern, "collection",
}; "add",
} collection.path,
const yaml = YAML.stringify({ collections }, { indent: 2, lineWidth: 0 }); "--name",
await fs.writeFile(this.collectionsFile, yaml, "utf-8"); collection.name,
"--mask",
// Also write legacy path so older qmd homes remain usable. collection.pattern,
await fs.writeFile(this.legacyCollectionsFile, yaml, "utf-8"); ]);
} } catch (err) {
const message = err instanceof Error ? err.message : String(err);
private async ensureLegacyCompatSymlinks(): Promise<void> { // Idempotency: qmd exits non-zero if the collection name already exists.
// Best-effort: keep legacy locations pointing at the XDG locations. if (message.includes("already exists")) continue;
// This helps when users have old state dirs on disk. log.warn(`qmd collection add failed for ${collection.name}: ${message}`);
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(