Skip to content

Commit b871c4a

Browse files
committed
fix(memory): complete recursive search follow-up
1 parent 8e700ba commit b871c4a

14 files changed

Lines changed: 173 additions & 42 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1545,6 +1545,7 @@ Docs: https://docs.openclaw.ai
15451545
- Replies: strip legacy `[TOOL_CALL]{tool => ..., args => ...}[/TOOL_CALL]` pseudo-call text from user-facing replies and flag it in tool-call diagnostics instead of showing raw tool syntax in channels. Fixes #63610. Thanks @canh0chua.
15461546
- WhatsApp: close long-lived web sockets through Baileys `end(error)` before falling back to raw websocket close, so listener teardown runs Baileys cleanup instead of leaving zombie sockets. Fixes #52442. Thanks @essendigitalgroup-cyber.
15471547
- Twitch/plugins: emit a flat JSON Schema for Twitch channel config so single-account and multi-account configs validate before runtime load, and add source-checkout diagnostics for missing pnpm workspace dependencies. Thanks @vincentkoc.
1548+
- Memory/memory_search: await dirty builtin memory index sync before agent-facing search, recognize nested dated memory paths, and watch memory roots directly so first-search results stay fresh for recursive `memory/**/*.md` changes. Fixes #52115 and #34400. Thanks @leafbird.
15481549
- Gateway/sessions: move hot transcript reads and mirror appends onto async bounded IO with serialized parent-linked writes, keeping large session histories from stalling Gateway requests and channel replies. Fixes #75656. Thanks @DerFlash.
15491550
- macOS/Talk Mode: downmix multi-channel microphone buffers before handing them to Apple Speech across Push-to-Talk, Talk Mode, Voice Wake, and the wake-word tester, so pro audio interfaces no longer produce empty transcripts. Fixes #42533. Thanks @jbuecker.
15501551
- macOS/Talk Mode: subscribe native WebChat to active-session transcript updates and render external spoken user turns in the chat thread instead of only showing assistant replies. Fixes #75155. Thanks @SledderBling.
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
c9e88800854b697cb3c9721d0087eb2bc7bcf6ae7239cb51d9849c49ef3d48e3 config-baseline.json
2-
67c58457ed2b525975cdb053489f92a5f840c8cf982666393e111fd327dd132e config-baseline.core.json
1+
f23d3f2eb622977a46509fd3fc9a66639af574f17da3fdeae0d40a071a849860 config-baseline.json
2+
e19c928031beed6691e694ad77e1fd86615bfdc741f2f20f982809ac16271c88 config-baseline.core.json
33
f90c9d96ccc4c0c703d6c489f86d89fde208cd7f697b396aeee96ff3ee087956 config-baseline.channel.json
44
18f71e9d4a62fe68fbd5bf18d5833a4e380fc705ad641769e1cf05794286344c config-baseline.plugin.json

extensions/memory-core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"dependencies": {
88
"chokidar": "^5.0.0",
99
"json5": "^2.2.3",
10+
"minimatch": "10.2.5",
1011
"typebox": "1.1.38"
1112
},
1213
"devDependencies": {

extensions/memory-core/src/memory/manager-async-state.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
1-
export function startAsyncSearchSync(params: {
1+
export async function awaitSearchSyncIfNeeded(params: {
22
enabled: boolean;
33
dirty: boolean;
44
sessionsDirty: boolean;
55
sync: (params: { reason: string }) => Promise<void>;
66
onError: (err: unknown) => void;
7-
}): void {
7+
}): Promise<void> {
88
if (!params.enabled || (!params.dirty && !params.sessionsDirty)) {
99
return;
1010
}
11-
void params.sync({ reason: "search" }).catch((err) => {
11+
try {
12+
await params.sync({ reason: "search" });
13+
} catch (err) {
1214
params.onError(err);
13-
});
15+
}
1416
}
1517

1618
export async function awaitPendingManagerWork(params: {

extensions/memory-core/src/memory/manager.async-search.test.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
import { describe, expect, it, vi } from "vitest";
2-
import { awaitPendingManagerWork, startAsyncSearchSync } from "./manager-async-state.js";
2+
import { awaitPendingManagerWork, awaitSearchSyncIfNeeded } from "./manager-async-state.js";
33

44
describe("memory search async sync", () => {
5-
it("does not await sync when searching", async () => {
5+
it("awaits sync when searching dirty indexes", async () => {
66
let releaseSync = () => {};
7+
let settled = false;
78
const pending = new Promise<void>((resolve) => {
8-
releaseSync = () => resolve();
9-
});
10-
const syncMock = vi.fn(async () => {
11-
return pending;
9+
releaseSync = () => {
10+
settled = true;
11+
resolve();
12+
};
1213
});
14+
const syncMock = vi.fn(async () => pending);
1315
const onError = vi.fn();
1416

15-
startAsyncSearchSync({
17+
const searchSyncPromise = awaitSearchSyncIfNeeded({
1618
enabled: true,
1719
dirty: true,
1820
sessionsDirty: false,
@@ -21,8 +23,17 @@ describe("memory search async sync", () => {
2123
});
2224

2325
expect(syncMock).toHaveBeenCalledTimes(1);
26+
let finished = false;
27+
void searchSyncPromise.then(() => {
28+
finished = true;
29+
});
30+
await Promise.resolve();
31+
expect(finished).toBe(false);
32+
expect(settled).toBe(false);
33+
2434
releaseSync();
25-
await pending;
35+
await searchSyncPromise;
36+
expect(finished).toBe(true);
2637
expect(onError).not.toHaveBeenCalled();
2738
});
2839

@@ -44,9 +55,9 @@ describe("memory search async sync", () => {
4455
await closePromise;
4556
});
4657

47-
it("skips background search sync when search-triggered sync is disabled", () => {
58+
it("skips search sync when search-triggered sync is disabled", async () => {
4859
const syncMock = vi.fn(async () => {});
49-
startAsyncSearchSync({
60+
await awaitSearchSyncIfNeeded({
5061
enabled: false,
5162
dirty: true,
5263
sessionsDirty: false,

extensions/memory-core/src/memory/manager.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {
2929
type EmbeddingProviderRuntime,
3030
} from "./embeddings.js";
3131
import { bm25RankToScore, buildFtsQuery, mergeHybridResults } from "./hybrid.js";
32-
import { awaitPendingManagerWork, startAsyncSearchSync } from "./manager-async-state.js";
32+
import { awaitPendingManagerWork, awaitSearchSyncIfNeeded } from "./manager-async-state.js";
3333
import { MEMORY_BATCH_FAILURE_LIMIT } from "./manager-batch-state.js";
3434
import {
3535
closeManagedCacheEntries,
@@ -335,7 +335,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
335335
}
336336
const cleaned = preflight.normalizedQuery;
337337
void this.warmSession(opts?.sessionKey);
338-
startAsyncSearchSync({
338+
await awaitSearchSyncIfNeeded({
339339
enabled: this.settings.sync.onSearch,
340340
dirty: this.dirty,
341341
sessionsDirty: this.sessionsDirty,
@@ -344,6 +344,9 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
344344
log.warn(`memory sync failed (search): ${String(err)}`);
345345
},
346346
});
347+
if (this.closed) {
348+
return [];
349+
}
347350
if (preflight.shouldInitializeProvider) {
348351
await this.ensureProviderInitialized();
349352
}

extensions/memory-core/src/memory/qmd-manager.test.ts

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -434,23 +434,51 @@ describe("QmdMemoryManager", () => {
434434

435435
const { manager } = await createManager({ mode: "full" });
436436
expect(watchMock).toHaveBeenCalledTimes(1);
437+
const firstWatchCall = watchMock.mock.calls[0];
438+
expect(firstWatchCall).toBeDefined();
439+
const [watchArgs, watchOptions] = firstWatchCall as unknown as [
440+
string[],
441+
{
442+
ignored?: (
443+
watchPath: string,
444+
stats: { isFile(): boolean; isDirectory(): boolean } | undefined,
445+
) => boolean;
446+
},
447+
];
448+
expect(watchArgs).toEqual([workspaceDir]);
449+
expect(typeof watchOptions.ignored).toBe("function");
450+
expect(watchOptions.ignored?.(workspaceDir, undefined)).toBe(false);
451+
expect(
452+
watchOptions.ignored?.(path.join(workspaceDir, ".git", "index"), {
453+
isFile: () => true,
454+
isDirectory: () => false,
455+
}),
456+
).toBe(true);
437457
const watcher = watchMock.mock.results[0]?.value as {
438458
emit: (event: string, ...args: unknown[]) => boolean;
439459
};
440460
const initialUpdateCalls = spawnMock.mock.calls.filter((call) => call[1]?.[0] === "update");
441461
expect(initialUpdateCalls).toHaveLength(0);
442-
const [, watchOptions] = watchMock.mock.calls[0] as unknown as [
443-
string[],
444-
{ ignored?: (watchPath: string) => boolean },
445-
];
446-
expect(watchOptions.ignored?.(path.join(workspaceDir, "node_modules", "pkg", "note.md"))).toBe(
462+
expect(
463+
watchOptions.ignored?.(path.join(workspaceDir, "node_modules", "pkg", "note.md"), undefined),
464+
).toBe(true);
465+
expect(
466+
watchOptions.ignored?.(path.join(workspaceDir, ".cache", "qmd", "note.md"), undefined),
467+
).toBe(true);
468+
expect(
469+
watchOptions.ignored?.(path.join(workspaceDir, "vendor", "pkg", "note.md"), undefined),
470+
).toBe(true);
471+
expect(watchOptions.ignored?.(path.join(workspaceDir, "dist", "note.md"), undefined)).toBe(
447472
true,
448473
);
449-
expect(watchOptions.ignored?.(path.join(workspaceDir, ".cache", "qmd", "note.md"))).toBe(true);
450-
expect(watchOptions.ignored?.(path.join(workspaceDir, "vendor", "pkg", "note.md"))).toBe(true);
451-
expect(watchOptions.ignored?.(path.join(workspaceDir, "dist", "note.md"))).toBe(true);
452-
expect(watchOptions.ignored?.(path.join(workspaceDir, "build", "note.md"))).toBe(true);
453-
expect(watchOptions.ignored?.(path.join(workspaceDir, "notes.md"))).toBe(false);
474+
expect(watchOptions.ignored?.(path.join(workspaceDir, "build", "note.md"), undefined)).toBe(
475+
true,
476+
);
477+
expect(watchOptions.ignored?.(path.join(workspaceDir, "notes.md"), undefined)).toBe(false);
478+
479+
watcher.emit("change", path.join(workspaceDir, "notes.txt"));
480+
await vi.advanceTimersByTimeAsync(25);
481+
expect(spawnMock.mock.calls.filter((call) => call[1]?.[0] === "update")).toHaveLength(0);
454482

455483
watcher.emit("change", path.join(workspaceDir, "notes.md"));
456484
expect(manager.status().dirty).toBe(true);

extensions/memory-core/src/memory/qmd-manager.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import os from "node:os";
55
import path from "node:path";
66
import readline from "node:readline";
77
import chokidar, { type FSWatcher } from "chokidar";
8+
import { minimatch } from "minimatch";
89
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
910
import { withFileLock } from "openclaw/plugin-sdk/file-lock";
1011
import {
@@ -192,14 +193,34 @@ function resolveQmdEmbedLockOptions(embedTimeoutMs: number) {
192193
};
193194
}
194195

195-
function shouldIgnoreMemoryWatchPath(watchPath: string): boolean {
196+
function shouldIgnoreMemoryWatchPath(
197+
watchPath: string,
198+
_stats: { isFile(): boolean; isDirectory(): boolean } | undefined,
199+
): boolean {
196200
const normalized = path.normalize(watchPath);
197201
const parts = normalized
198202
.split(path.sep)
199203
.map((segment) => normalizeLowercaseStringOrEmpty(segment));
200204
return parts.some((segment) => IGNORED_MEMORY_WATCH_DIR_NAMES.has(segment));
201205
}
202206

207+
function shouldHandleMemoryWatchEvent(
208+
watchPath: string,
209+
collections: ManagedCollection[],
210+
): boolean {
211+
const normalized = path.normalize(watchPath);
212+
return collections.some((collection) => {
213+
if (collection.kind === "sessions") {
214+
return false;
215+
}
216+
const relativePath = path.relative(path.normalize(collection.path), normalized);
217+
if (!relativePath || relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
218+
return false;
219+
}
220+
return minimatch(relativePath.replaceAll("\\", "/"), collection.pattern, { dot: true });
221+
});
222+
}
223+
203224
type CollectionRoot = {
204225
path: string;
205226
kind: MemorySource;
@@ -1550,10 +1571,10 @@ export class QmdMemoryManager implements MemorySearchManager {
15501571
return;
15511572
}
15521573
const watchPaths = new Set<string>();
1553-
for (const collection of this.qmd.collections) {
1554-
if (collection.kind === "sessions") {
1555-
continue;
1556-
}
1574+
const watchedCollections = this.qmd.collections.filter(
1575+
(collection) => collection.kind !== "sessions",
1576+
);
1577+
for (const collection of watchedCollections) {
15571578
watchPaths.add(this.resolveCollectionWatchPath(collection));
15581579
}
15591580
if (watchPaths.size === 0) {
@@ -1564,13 +1585,16 @@ export class QmdMemoryManager implements MemorySearchManager {
15641585
log.info(`qmd watcher starting for agent "${this.agentId}" paths=${watchPathList.length}`);
15651586
this.watcher = chokidar.watch(watchPathList, {
15661587
ignoreInitial: true,
1567-
ignored: (watchPath) => shouldIgnoreMemoryWatchPath(watchPath),
1588+
ignored: (watchPath, stats) => shouldIgnoreMemoryWatchPath(watchPath, stats),
15681589
awaitWriteFinish: {
15691590
stabilityThreshold: QMD_WATCH_STABILITY_MS,
15701591
pollInterval: 100,
15711592
},
15721593
});
1573-
const markDirty = () => {
1594+
const markDirty = (watchPath: string) => {
1595+
if (!shouldHandleMemoryWatchEvent(watchPath, watchedCollections)) {
1596+
return;
1597+
}
15741598
this.dirty = true;
15751599
this.scheduleWatchSync();
15761600
};
@@ -1585,7 +1609,7 @@ export class QmdMemoryManager implements MemorySearchManager {
15851609
}
15861610

15871611
private resolveCollectionWatchPath(collection: ManagedCollection): string {
1588-
return path.join(path.normalize(collection.path), collection.pattern);
1612+
return path.normalize(collection.path);
15891613
}
15901614

15911615
private scheduleWatchSync(): void {

extensions/memory-core/src/memory/temporal-decay.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,64 @@ describe("temporal decay", () => {
9090
expect(decayed[1]?.score).toBeCloseTo(0.75);
9191
});
9292

93+
it("uses dated path decay for nested memory files", async () => {
94+
const merged = await mergeVectorResultsWithTemporalDecay([
95+
createVectorMemoryEntry({
96+
id: "nested-old",
97+
path: "memory/2025-01/2025-01-01.md",
98+
snippet: "old nested",
99+
vectorScore: 0.95,
100+
}),
101+
createVectorMemoryEntry({
102+
id: "nested-new",
103+
path: "memory/2026-02/2026-02-10.md",
104+
snippet: "new nested",
105+
vectorScore: 0.8,
106+
}),
107+
]);
108+
109+
expect(merged[0]?.path).toBe("memory/2026-02/2026-02-10.md");
110+
expect(merged[0]?.score ?? 0).toBeGreaterThan(merged[1]?.score ?? 0);
111+
});
112+
113+
it("does not misclassify nested dated memory files as evergreen", async () => {
114+
const dir = await createTempWorkspace("openclaw-temporal-decay-");
115+
const nestedPath = path.join(dir, "memory", "2026-02", "2026-02-10.md");
116+
await fs.mkdir(path.dirname(nestedPath), { recursive: true });
117+
await fs.writeFile(nestedPath, "dated nested memory");
118+
119+
const veryOld = new Date(Date.UTC(2010, 0, 1));
120+
await fs.utimes(nestedPath, veryOld, veryOld);
121+
122+
const decayed = await applyTemporalDecayToHybridResults({
123+
results: [{ path: "memory/2026-02/2026-02-10.md", score: 1, source: "memory" }],
124+
workspaceDir: dir,
125+
temporalDecay: { enabled: true, halfLifeDays: 30 },
126+
nowMs: NOW_MS,
127+
});
128+
129+
expect(decayed[0]?.score).toBeCloseTo(1);
130+
});
131+
132+
it("supports nested dated memory files under non-ascii or dotted directories", async () => {
133+
const merged = await mergeVectorResultsWithTemporalDecay([
134+
createVectorMemoryEntry({
135+
id: "old-nonascii",
136+
path: "memory/프로젝트.v1/2025-01-01.md",
137+
snippet: "old nonascii",
138+
vectorScore: 0.95,
139+
}),
140+
createVectorMemoryEntry({
141+
id: "new-nonascii",
142+
path: "memory/프로젝트.v1/2026-02-10.md",
143+
snippet: "new nonascii",
144+
vectorScore: 0.8,
145+
}),
146+
]);
147+
148+
expect(merged[0]?.path).toBe("memory/프로젝트.v1/2026-02-10.md");
149+
});
150+
93151
it("applies decay in hybrid merging before ranking", async () => {
94152
const merged = await mergeVectorResultsWithTemporalDecay([
95153
createVectorMemoryEntry({

extensions/memory-core/src/memory/temporal-decay.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export const DEFAULT_TEMPORAL_DECAY_CONFIG: TemporalDecayConfig = {
1212
};
1313

1414
const DAY_MS = 24 * 60 * 60 * 1000;
15-
const DATED_MEMORY_PATH_RE = /(?:^|\/)memory\/(\d{4})-(\d{2})-(\d{2})\.md$/;
15+
const DATED_MEMORY_PATH_RE = /(?:^|\/)memory\/(?:[^/]+\/)*(\d{4})-(\d{2})-(\d{2})\.md$/;
1616

1717
function toDecayLambda(halfLifeDays: number): number {
1818
if (!Number.isFinite(halfLifeDays) || halfLifeDays <= 0) {

0 commit comments

Comments
 (0)