fix(memory/qmd): throttle embed + citations auto + restore --force

This commit is contained in:
vignesh07 2026-01-28 02:05:58 -08:00
parent ebf6553c9e
commit d16d721898
11 changed files with 90 additions and 18 deletions

View File

@ -162,7 +162,7 @@ out to QMD for retrieval. Key points:
stable `name`). stable `name`).
- `sessions`: opt into session JSONL indexing (`enabled`, `retentionDays`, - `sessions`: opt into session JSONL indexing (`enabled`, `retentionDays`,
`exportDir`). `exportDir`).
- `update`: controls refresh cadence (`interval`, `debounceMs`, `onBoot`). - `update`: controls refresh cadence (`interval`, `debounceMs`, `onBoot`, `embedInterval`).
- `limits`: clamp recall payload (`maxResults`, `maxSnippetChars`, - `limits`: clamp recall payload (`maxResults`, `maxSnippetChars`,
`maxInjectedChars`, `timeoutMs`). `maxInjectedChars`, `timeoutMs`).
- `scope`: same schema as [`session.sendPolicy`](/reference/configuration#session-sendpolicy). - `scope`: same schema as [`session.sendPolicy`](/reference/configuration#session-sendpolicy).

View File

@ -51,7 +51,10 @@ export function createMemorySearchTool(options: {
} }
try { try {
const citationsMode = resolveMemoryCitationsMode(cfg); const citationsMode = resolveMemoryCitationsMode(cfg);
const includeCitations = citationsMode !== "off"; const includeCitations = shouldIncludeCitations({
mode: citationsMode,
sessionKey: options.agentSessionKey,
});
const rawResults = await manager.search(query, { const rawResults = await manager.search(query, {
maxResults, maxResults,
minScore, minScore,
@ -141,3 +144,21 @@ function formatCitation(entry: MemorySearchResult): string {
: `#L${entry.startLine}-L${entry.endLine}`; : `#L${entry.startLine}-L${entry.endLine}`;
return `${entry.path}${lineRange}`; return `${entry.path}${lineRange}`;
} }
function shouldIncludeCitations(params: {
mode: MemoryCitationsMode;
sessionKey?: string;
}): boolean {
if (params.mode === "on") return true;
if (params.mode === "off") return false;
// auto: show citations in direct chats; suppress in groups/channels by default.
const chatType = deriveChatTypeFromSessionKey(params.sessionKey);
return chatType === "direct";
}
function deriveChatTypeFromSessionKey(sessionKey?: string): "direct" | "group" | "channel" {
if (!sessionKey) return "direct";
if (sessionKey.includes(":group:")) return "group";
if (sessionKey.includes(":channel:")) return "channel";
return "direct";
}

View File

@ -242,7 +242,7 @@ describe("memory cli", () => {
await program.parseAsync(["memory", "status", "--index"], { from: "user" }); await program.parseAsync(["memory", "status", "--index"], { from: "user" });
expect(sync).toHaveBeenCalledWith( expect(sync).toHaveBeenCalledWith(
expect.objectContaining({ reason: "cli", force: true, progress: expect.any(Function) }), expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
); );
expect(probeEmbeddingAvailability).toHaveBeenCalled(); expect(probeEmbeddingAvailability).toHaveBeenCalled();
expect(close).toHaveBeenCalled(); expect(close).toHaveBeenCalled();
@ -267,7 +267,7 @@ describe("memory cli", () => {
await program.parseAsync(["memory", "index"], { from: "user" }); await program.parseAsync(["memory", "index"], { from: "user" });
expect(sync).toHaveBeenCalledWith( expect(sync).toHaveBeenCalledWith(
expect.objectContaining({ reason: "cli", force: true, progress: expect.any(Function) }), expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
); );
expect(close).toHaveBeenCalled(); expect(close).toHaveBeenCalled();
expect(log).toHaveBeenCalledWith("Memory index updated (main)."); expect(log).toHaveBeenCalledWith("Memory index updated (main).");
@ -294,7 +294,7 @@ describe("memory cli", () => {
await program.parseAsync(["memory", "index"], { from: "user" }); await program.parseAsync(["memory", "index"], { from: "user" });
expect(sync).toHaveBeenCalledWith( expect(sync).toHaveBeenCalledWith(
expect.objectContaining({ reason: "cli", force: true, progress: expect.any(Function) }), expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
); );
expect(close).toHaveBeenCalled(); expect(close).toHaveBeenCalled();
expect(error).toHaveBeenCalledWith( expect(error).toHaveBeenCalledWith(

View File

@ -24,6 +24,7 @@ type MemoryCommandOptions = {
json?: boolean; json?: boolean;
deep?: boolean; deep?: boolean;
index?: boolean; index?: boolean;
force?: boolean;
verbose?: boolean; verbose?: boolean;
}; };
@ -243,7 +244,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
try { try {
await syncFn({ await syncFn({
reason: "cli", reason: "cli",
force: true, force: Boolean(opts.force),
progress: (syncUpdate) => { progress: (syncUpdate) => {
update({ update({
completed: syncUpdate.completed, completed: syncUpdate.completed,
@ -445,7 +446,7 @@ export function registerMemoryCli(program: Command) {
.option("--deep", "Probe embedding provider availability") .option("--deep", "Probe embedding provider availability")
.option("--index", "Reindex if dirty (implies --deep)") .option("--index", "Reindex if dirty (implies --deep)")
.option("--verbose", "Verbose logging", false) .option("--verbose", "Verbose logging", false)
.action(async (opts: MemoryCommandOptions) => { .action(async (opts: MemoryCommandOptions & { force?: boolean }) => {
await runMemoryStatus(opts); await runMemoryStatus(opts);
}); });
@ -453,6 +454,7 @@ export function registerMemoryCli(program: Command) {
.command("index") .command("index")
.description("Reindex memory files") .description("Reindex memory files")
.option("--agent <id>", "Agent id (default: default agent)") .option("--agent <id>", "Agent id (default: default agent)")
.option("--force", "Force full reindex", false)
.option("--verbose", "Verbose logging", false) .option("--verbose", "Verbose logging", false)
.action(async (opts: MemoryCommandOptions) => { .action(async (opts: MemoryCommandOptions) => {
setVerbose(Boolean(opts.verbose)); setVerbose(Boolean(opts.verbose));
@ -545,7 +547,7 @@ export function registerMemoryCli(program: Command) {
try { try {
await syncFn({ await syncFn({
reason: "cli", reason: "cli",
force: true, force: Boolean(opts.force),
progress: (syncUpdate) => { progress: (syncUpdate) => {
if (syncUpdate.label) lastLabel = syncUpdate.label; if (syncUpdate.label) lastLabel = syncUpdate.label;
lastCompleted = syncUpdate.completed; lastCompleted = syncUpdate.completed;

View File

@ -267,6 +267,7 @@ const FIELD_LABELS: Record<string, string> = {
"memory.qmd.update.interval": "QMD Update Interval", "memory.qmd.update.interval": "QMD Update Interval",
"memory.qmd.update.debounceMs": "QMD Update Debounce (ms)", "memory.qmd.update.debounceMs": "QMD Update Debounce (ms)",
"memory.qmd.update.onBoot": "QMD Update on Startup", "memory.qmd.update.onBoot": "QMD Update on Startup",
"memory.qmd.update.embedInterval": "QMD Embed Interval",
"memory.qmd.limits.maxResults": "QMD Max Results", "memory.qmd.limits.maxResults": "QMD Max Results",
"memory.qmd.limits.maxSnippetChars": "QMD Max Snippet Chars", "memory.qmd.limits.maxSnippetChars": "QMD Max Snippet Chars",
"memory.qmd.limits.maxInjectedChars": "QMD Max Injected Chars", "memory.qmd.limits.maxInjectedChars": "QMD Max Injected Chars",
@ -580,6 +581,8 @@ const FIELD_HELP: Record<string, string> = {
"memory.qmd.update.debounceMs": "memory.qmd.update.debounceMs":
"Minimum delay between successive QMD refresh runs (default: 15000).", "Minimum delay between successive QMD refresh runs (default: 15000).",
"memory.qmd.update.onBoot": "Run QMD update once on gateway startup (default: true).", "memory.qmd.update.onBoot": "Run QMD update once on gateway startup (default: true).",
"memory.qmd.update.embedInterval":
"How often QMD embeddings are refreshed (duration string, default: 60m). Set to 0 to disable periodic embed.",
"memory.qmd.limits.maxResults": "Max QMD results returned to the agent loop (default: 6).", "memory.qmd.limits.maxResults": "Max QMD results returned to the agent loop (default: 6).",
"memory.qmd.limits.maxSnippetChars": "Max characters per snippet pulled from QMD (default: 700).", "memory.qmd.limits.maxSnippetChars": "Max characters per snippet pulled from QMD (default: 700).",
"memory.qmd.limits.maxInjectedChars": "Max total characters injected from QMD hits per turn.", "memory.qmd.limits.maxInjectedChars": "Max total characters injected from QMD hits per turn.",

View File

@ -35,6 +35,7 @@ export type MemoryQmdUpdateConfig = {
interval?: string; interval?: string;
debounceMs?: number; debounceMs?: number;
onBoot?: boolean; onBoot?: boolean;
embedInterval?: string;
}; };
export type MemoryQmdLimitsConfig = { export type MemoryQmdLimitsConfig = {

View File

@ -53,6 +53,7 @@ const MemoryQmdUpdateSchema = z
interval: z.string().optional(), interval: z.string().optional(),
debounceMs: z.number().int().nonnegative().optional(), debounceMs: z.number().int().nonnegative().optional(),
onBoot: z.boolean().optional(), onBoot: z.boolean().optional(),
embedInterval: z.string().optional(),
}) })
.strict(); .strict();

View File

@ -29,6 +29,7 @@ export type ResolvedQmdUpdateConfig = {
intervalMs: number; intervalMs: number;
debounceMs: number; debounceMs: number;
onBoot: boolean; onBoot: boolean;
embedIntervalMs: number;
}; };
export type ResolvedQmdLimitsConfig = { export type ResolvedQmdLimitsConfig = {
@ -59,6 +60,7 @@ const DEFAULT_CITATIONS: MemoryCitationsMode = "auto";
const DEFAULT_QMD_INTERVAL = "5m"; const DEFAULT_QMD_INTERVAL = "5m";
const DEFAULT_QMD_DEBOUNCE_MS = 15_000; const DEFAULT_QMD_DEBOUNCE_MS = 15_000;
const DEFAULT_QMD_TIMEOUT_MS = 4_000; const DEFAULT_QMD_TIMEOUT_MS = 4_000;
const DEFAULT_QMD_EMBED_INTERVAL = "60m";
const DEFAULT_QMD_LIMITS: ResolvedQmdLimitsConfig = { const DEFAULT_QMD_LIMITS: ResolvedQmdLimitsConfig = {
maxResults: 6, maxResults: 6,
maxSnippetChars: 700, maxSnippetChars: 700,
@ -115,6 +117,16 @@ function resolveIntervalMs(raw: string | undefined): number {
} }
} }
function resolveEmbedIntervalMs(raw: string | undefined): number {
const value = raw?.trim();
if (!value) return parseDurationMs(DEFAULT_QMD_EMBED_INTERVAL, { defaultUnit: "m" });
try {
return parseDurationMs(value, { defaultUnit: "m" });
} catch {
return parseDurationMs(DEFAULT_QMD_EMBED_INTERVAL, { defaultUnit: "m" });
}
}
function resolveDebounceMs(raw: number | undefined): number { function resolveDebounceMs(raw: number | undefined): number {
if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) { if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) {
return Math.floor(raw); return Math.floor(raw);
@ -221,7 +233,7 @@ export function resolveMemoryBackendConfig(params: {
]; ];
const resolved: ResolvedQmdConfig = { const resolved: ResolvedQmdConfig = {
command: qmdCfg?.command?.trim() || "qmd", command: (qmdCfg?.command?.trim() || "qmd").split(/\s+/)[0] || "qmd",
collections, collections,
includeDefaultMemory, includeDefaultMemory,
sessions: resolveSessionConfig(qmdCfg?.sessions, workspaceDir), sessions: resolveSessionConfig(qmdCfg?.sessions, workspaceDir),
@ -229,6 +241,7 @@ export function resolveMemoryBackendConfig(params: {
intervalMs: resolveIntervalMs(qmdCfg?.update?.interval), intervalMs: resolveIntervalMs(qmdCfg?.update?.interval),
debounceMs: resolveDebounceMs(qmdCfg?.update?.debounceMs), debounceMs: resolveDebounceMs(qmdCfg?.update?.debounceMs),
onBoot: qmdCfg?.update?.onBoot !== false, onBoot: qmdCfg?.update?.onBoot !== false,
embedIntervalMs: resolveEmbedIntervalMs(qmdCfg?.update?.embedInterval),
}, },
limits: resolveLimits(qmdCfg?.limits), limits: resolveLimits(qmdCfg?.limits),
scope: qmdCfg?.scope ?? DEFAULT_QMD_SCOPE, scope: qmdCfg?.scope ?? DEFAULT_QMD_SCOPE,

View File

@ -90,7 +90,8 @@ describe("QmdMemoryManager", () => {
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(baselineCalls + 4); // By default we refresh embeddings less frequently than index updates.
expect(spawnMock.mock.calls.length).toBe(baselineCalls + 3);
await manager.close(); await manager.close();
}); });

View File

@ -84,6 +84,7 @@ export class QmdMemoryManager implements MemorySearchManager {
private closed = false; private closed = false;
private db: SqliteDatabase | null = null; private db: SqliteDatabase | null = null;
private lastUpdateAt: number | null = null; private lastUpdateAt: number | null = null;
private lastEmbedAt: number | null = null;
private constructor(params: { private constructor(params: {
cfg: MoltbotConfig; cfg: MoltbotConfig;
@ -165,9 +166,27 @@ export class QmdMemoryManager implements MemorySearchManager {
private async ensureCollections(): Promise<void> { private async ensureCollections(): Promise<void> {
// QMD collections are persisted inside the index database and must be created // 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 // via the CLI. Prefer listing existing collections when supported, otherwise
// target, so we ensure collections exist by running `qmd collection add`. // fall back to best-effort idempotent `qmd collection add`.
const existing = new Set<string>();
try {
const result = await this.runQmd(["collection", "list", "--json"]);
const parsed = JSON.parse(result.stdout) as unknown;
if (Array.isArray(parsed)) {
for (const entry of parsed) {
if (typeof entry === "string") existing.add(entry);
else if (entry && typeof entry === "object") {
const name = (entry as { name?: unknown }).name;
if (typeof name === "string") existing.add(name);
}
}
}
} catch {
// ignore; older qmd versions might not support list --json.
}
for (const collection of this.qmd.collections) { for (const collection of this.qmd.collections) {
if (existing.has(collection.name)) continue;
try { try {
await this.runQmd([ await this.runQmd([
"collection", "collection",
@ -181,7 +200,8 @@ export class QmdMemoryManager implements MemorySearchManager {
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
// Idempotency: qmd exits non-zero if the collection name already exists. // Idempotency: qmd exits non-zero if the collection name already exists.
if (message.includes("already exists")) continue; if (message.toLowerCase().includes("already exists")) continue;
if (message.toLowerCase().includes("exists")) continue;
log.warn(`qmd collection add failed for ${collection.name}: ${message}`); log.warn(`qmd collection add failed for ${collection.name}: ${message}`);
} }
} }
@ -335,10 +355,18 @@ export class QmdMemoryManager implements MemorySearchManager {
await this.exportSessions(); await this.exportSessions();
} }
await this.runQmd(["update"], { timeoutMs: 120_000 }); await this.runQmd(["update"], { timeoutMs: 120_000 });
try { const embedIntervalMs = this.qmd.update.embedIntervalMs;
await this.runQmd(["embed"], { timeoutMs: 120_000 }); const shouldEmbed =
} catch (err) { Boolean(force) ||
log.warn(`qmd embed failed (${reason}): ${String(err)}`); this.lastEmbedAt === null ||
(embedIntervalMs > 0 && Date.now() - this.lastEmbedAt > embedIntervalMs);
if (shouldEmbed) {
try {
await this.runQmd(["embed"], { timeoutMs: 120_000 });
this.lastEmbedAt = Date.now();
} catch (err) {
log.warn(`qmd embed failed (${reason}): ${String(err)}`);
}
} }
this.lastUpdateAt = Date.now(); this.lastUpdateAt = Date.now();
this.docPathCache.clear(); this.docPathCache.clear();

View File

@ -3,6 +3,7 @@ import path from "node:path";
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js"; import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
import { createSubsystemLogger } from "../logging/subsystem.js"; import { createSubsystemLogger } from "../logging/subsystem.js";
import { redactSensitiveText } from "../logging/redact.js";
import { hashText } from "./internal.js"; import { hashText } from "./internal.js";
const log = createSubsystemLogger("memory"); const log = createSubsystemLogger("memory");
@ -87,8 +88,9 @@ export async function buildSessionEntry(absPath: string): Promise<SessionFileEnt
if (message.role !== "user" && message.role !== "assistant") continue; if (message.role !== "user" && message.role !== "assistant") continue;
const text = extractSessionText(message.content); const text = extractSessionText(message.content);
if (!text) continue; if (!text) continue;
const safe = redactSensitiveText(text, { mode: "tools" });
const label = message.role === "user" ? "User" : "Assistant"; const label = message.role === "user" ? "User" : "Assistant";
collected.push(`${label}: ${text}`); collected.push(`${label}: ${safe}`);
} }
const content = collected.join("\n"); const content = collected.join("\n");
return { return {