From 0fdcb3be4329aed78d82be366f435d15e19f4a18 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sat, 14 Feb 2026 14:57:31 -0800 Subject: [PATCH] Memory/QMD: skip unchanged session export writes --- CHANGELOG.md | 1 + src/memory/qmd-manager.test.ts | 52 ++++++++++++++++++++++++++++++++++ src/memory/qmd-manager.ts | 25 +++++++++++++++- 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 256b692ef..b7eba5970 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,7 @@ Docs: https://docs.openclaw.ai - Memory/QMD: make QMD result JSON parsing resilient to noisy command output by extracting the first JSON array from noisy `stdout`. - Memory/QMD: pass result limits to `search`/`vsearch` commands so QMD can cap results earlier. - Memory/QMD: avoid reading full markdown files when a `from/lines` window is requested in QMD reads. +- Memory/QMD: skip rewriting unchanged session export markdown files during sync to reduce disk churn. - Models/CLI: guard `models status` string trimming paths to prevent crashes from malformed non-string config values. (#16395) Thanks @BinHPdev. ## 2026.2.14 diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index a8d3b4c97..93d8681d5 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -809,6 +809,58 @@ describe("QmdMemoryManager", () => { readFileSpy.mockRestore(); }); + it("reuses exported session markdown files when inputs are unchanged", async () => { + const writeFileSpy = vi.spyOn(fs, "writeFile"); + const sessionsDir = path.join(stateDir, "agents", agentId, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + const sessionFile = path.join(sessionsDir, "session-1.jsonl"); + await fs.writeFile( + sessionFile, + '{"type":"message","message":{"role":"user","content":"hello"}}\n', + "utf-8", + ); + + cfg = { + ...cfg, + memory: { + ...cfg.memory, + qmd: { + ...cfg.memory.qmd, + sessions: { + enabled: true, + }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveMemoryBackendConfig({ cfg, agentId }); + const manager = await QmdMemoryManager.create({ cfg, agentId, resolved }); + expect(manager).toBeTruthy(); + if (!manager) { + throw new Error("manager missing"); + } + + const reasonCount = writeFileSpy.mock.calls.length; + await manager.sync({ reason: "manual" }); + const firstExportWrites = writeFileSpy.mock.calls.length; + expect(firstExportWrites).toBe(reasonCount + 1); + + await manager.sync({ reason: "manual" }); + expect(writeFileSpy.mock.calls.length).toBe(firstExportWrites); + + await new Promise((resolve) => setTimeout(resolve, 5)); + await fs.writeFile( + sessionFile, + '{"type":"message","message":{"role":"user","content":"follow-up update"}}\n', + "utf-8", + ); + await manager.sync({ reason: "manual" }); + expect(writeFileSpy.mock.calls.length).toBe(firstExportWrites + 1); + + await manager.close(); + writeFileSpy.mockRestore(); + }); + it("throws when sqlite index is busy", async () => { const resolved = resolveMemoryBackendConfig({ cfg, agentId }); const manager = await QmdMemoryManager.create({ cfg, agentId, resolved }); diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index bd15786b3..d2e9bbca3 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -76,6 +76,14 @@ export class QmdMemoryManager implements MemorySearchManager { string, { rel: string; abs: string; source: MemorySource } >(); + private readonly exportedSessionState = new Map< + string, + { + hash: string; + mtimeMs: number; + target: string; + } + >(); private readonly maxQmdOutputChars = MAX_QMD_OUTPUT_CHARS; private readonly sessionExporter: SessionExporterConfig | null; private updateTimer: NodeJS.Timeout | null = null; @@ -662,6 +670,7 @@ export class QmdMemoryManager implements MemorySearchManager { await fs.mkdir(exportDir, { recursive: true }); const files = await listSessionFilesForAgent(this.agentId); const keep = new Set(); + const tracked = new Set(); const cutoff = this.sessionExporter.retentionMs ? Date.now() - this.sessionExporter.retentionMs : null; @@ -674,7 +683,16 @@ export class QmdMemoryManager implements MemorySearchManager { continue; } const target = path.join(exportDir, `${path.basename(sessionFile, ".jsonl")}.md`); - await fs.writeFile(target, this.renderSessionMarkdown(entry), "utf-8"); + tracked.add(sessionFile); + const state = this.exportedSessionState.get(sessionFile); + if (!state || state.hash !== entry.hash || state.mtimeMs !== entry.mtimeMs) { + await fs.writeFile(target, this.renderSessionMarkdown(entry), "utf-8"); + } + this.exportedSessionState.set(sessionFile, { + hash: entry.hash, + mtimeMs: entry.mtimeMs, + target, + }); keep.add(target); } const exported = await fs.readdir(exportDir).catch(() => []); @@ -687,6 +705,11 @@ export class QmdMemoryManager implements MemorySearchManager { await fs.rm(full, { force: true }); } } + for (const [sessionFile, state] of this.exportedSessionState) { + if (!tracked.has(sessionFile) || !state.target.startsWith(exportDir + path.sep)) { + this.exportedSessionState.delete(sessionFile); + } + } } private renderSessionMarkdown(entry: SessionFileEntry): string {