Question:
Our short-term-memory promotion engine ranks snippets by how often they get recalled. It works when humans drive recall from the `openclaw memory search` command, but when the model calls the `memory_search` tool during a session, those recalls never show up in the promotion store, so model-driven usage never promotes anything.

I wired the tool the same way the command does: right after the semantic search returns, I fire the recall recorder with the search results, without awaiting it so a slow write can't stall the tool, swallowing any error. I pull the timezone off the agent's configured user timezone so the day buckets line up with when the user is active. But promotions still look off — snippets that the model clearly leaned on aren't getting promoted, and some stored entries look weirdly mangled (they have trailing `Source:` reference lines baked into them, and a few look cut off mid-sentence). Other entries that the model never even saw seem to be getting counted.

Walk me through what the tool path actually has to do differently from the command path here, and why my current wiring produces those mangled / phantom entries. What exactly should get handed to the recorder, and keyed how?

```ts
import type {
  MemorySearchManager,
  MemorySearchResult,
  MemorySearchRuntimeDebug,
} from 'openclaw/plugin-sdk/memory-core-host-runtime-files';
import type { OpenClawConfig } from 'openclaw/plugin-sdk/memory-core-host-runtime-core';
import {
  resolveMemoryCorePluginConfig,
  resolveMemoryDeepDreamingConfig,
} from 'openclaw/plugin-sdk/memory-core-host-status';
import { recordShortTermRecalls } from './short-term-promotion.js';
import { clampResultsByInjectedChars, decorateCitations } from './tools.citations.js';
import { loadMemoryToolRuntime } from './tools.shared.js';

// The promotion store keys recalls by the snapshot's stable result identity:
// (source, path, startLine, endLine) — NOT by snippet text or a tool-call id.
function buildRecallKey(
  result: Pick<MemorySearchResult, 'source' | 'path' | 'startLine' | 'endLine'>,
): string {
  return `${result.source}:${result.path}:${result.startLine}:${result.endLine}`;
}

// The tool path's extra step the command path never needs: map each result that
// was actually SURFACED to the model (post-decorate + post-qmd-clamp) back to its
// RAW counterpart, so the recorded snippet is the raw body — not the citation-
// decorated ("Source:" line baked in) / clamp-truncated text the model saw.
function resolveRecallTrackingResults(
  rawResults: MemorySearchResult[],
  surfacedResults: MemorySearchResult[],
): MemorySearchResult[] {
  if (surfacedResults.length === 0 || rawResults.length === 0) {
    return surfacedResults;
  }
  const rawByKey = new Map<string, MemorySearchResult>();
  for (const raw of rawResults) {
    const key = buildRecallKey(raw);
    if (!rawByKey.has(key)) {
      rawByKey.set(key, raw);
    }
  }
  return surfacedResults.map((surfaced) => rawByKey.get(buildRecallKey(surfaced)) ?? surfaced);
}

function queueShortTermRecallTracking(params: {
  workspaceDir?: string;
  query: string;
  rawResults: MemorySearchResult[];
  surfacedResults: MemorySearchResult[];
  timezone?: string;
}): void {
  const trackingResults = resolveRecallTrackingResults(params.rawResults, params.surfacedResults);
  // Best-effort: fire without awaiting and swallow errors so a slow/failed
  // recall write can never stall or fail the memory_search tool.
  void recordShortTermRecalls({
    workspaceDir: params.workspaceDir,
    query: params.query,
    results: trackingResults,
    timezone: params.timezone,
  }).catch(() => {
    // Recall tracking is best-effort and must never block memory recall.
  });
}

// Inside the memory_search tool's execute closure, after semantic search:
async function wireToolRecallTracking(args: {
  cfg: OpenClawConfig;
  agentId: string;
  query: string;
  maxResults?: number;
  minScore?: number;
  includeCitations: boolean;
  memory: { manager: MemorySearchManager };
  options: { agentSessionKey?: string };
}): Promise<MemorySearchResult[]> {
  const { cfg, agentId, query, maxResults, minScore, includeCitations, memory, options } = args;
  const { resolveMemoryBackendConfig } = await loadMemoryToolRuntime();
  const runtimeDebug: MemorySearchRuntimeDebug[] = [];

  const rawResults = await memory.manager.search(query, {
    maxResults,
    minScore,
    sessionKey: options.agentSessionKey,
    onDebug: (debug) => {
      runtimeDebug.push(debug);
    },
  });
  const status = memory.manager.status();
  const decorated = decorateCitations(rawResults, includeCitations);
  const resolved = resolveMemoryBackendConfig({ cfg, agentId });
  const memoryResults =
    status.backend === 'qmd'
      ? clampResultsByInjectedChars(decorated, resolved.qmd?.limits.maxInjectedChars)
      : decorated;

  // Day buckets must line up with the Dreaming engine, so the timezone comes from
  // the memory-core Dreaming config — NOT agents.defaults.userTimezone.
  const timezone = resolveMemoryDeepDreamingConfig({
    pluginConfig: resolveMemoryCorePluginConfig(cfg),
    cfg,
  }).timezone;

  queueShortTermRecallTracking({
    workspaceDir: status.workspaceDir,
    query,
    rawResults,
    surfacedResults: memoryResults,
    timezone,
  });

  return memoryResults;
}
```

Queues recall tracking AFTER citation-decoration + qmd-clamp using the SURFACED subset (what the model actually saw), but remaps each surfaced result back to its RAW counterpart so the stored snippet is the raw body. It must NOT hand the recorder the full raw/manager.search result list (which records phantom entries the model never saw after the clamp dropped them) and must NOT hand it the decorated/clamp-truncated snippets (which bake in the trailing 'Source:' citation line and mid-sentence cut-offs).

Keys/identifies each recorded result by the stable (source, path, startLine, endLine) tuple that recordShortTermRecalls uses for its entry identity (e.g. building a `${source}:${path}:${startLine}:${endLine}` key for the surfaced->raw remap) — NOT by a fabricated canonical snippet-id, a per-tool-call id, or the snippet text.

Resolves the day-bucket timezone from the memory-core Dreaming config via resolveMemoryDeepDreamingConfig (over resolveMemoryCorePluginConfig(cfg)) and passes it into recordShortTermRecalls — NOT from agents.defaults.userTimezone / the agent's configured user timezone.

Invokes recordShortTermRecalls WITHOUT awaiting it and swallows failures (void + .catch, best-effort, non-blocking) — affirming the existing fire-and-forget wiring rather than converting it to an awaited/blocking call.

Passes the memory workspace dir (status.workspaceDir) and the query into the queued recordShortTermRecalls call.

0024:   buildMemorySearchUnavailableResult,
0025:   createMemoryTool,
0026:   getMemoryCorpusSupplementResult,
0027:   getMemoryManagerContext,
0028:   getMemoryManagerContextWithPurpose,
0029:   loadMemoryToolRuntime,
0030:   MemoryGetSchema,
0031:   MemorySearchSchema,
0032:   searchMemoryCorpusSupplements,

0249:             const status = memory.manager.status();
0250:             const decorated = decorateCitations(rawResults, includeCitations);
0251:             const resolved = resolveMemoryBackendConfig({ cfg, agentId });
0252:             const memoryResults =
0253:               status.backend === "qmd"
0254:                 ? clampResultsByInjectedChars(decorated, resolved.qmd?.limits.maxInjectedChars)
0255:                 : decorated;
0256:             surfacedMemoryResults = memoryResults.map((result) => ({
0257:               ...result,
0258:               corpus: "memory" as const,
0259:             }));
0260:             const sleepTimezone = resolveMemoryDeepDreamingConfig({
0261:               pluginConfig: resolveMemoryCorePluginConfig(cfg),
0262:               cfg,
0263:             }).timezone;
0264:             queueShortTermRecallTracking({
0265:               workspaceDir: status.workspaceDir,
0266:               query,
0267:               rawResults,
0268:               surfacedResults: memoryResults,
0269:               timezone: sleepTimezone,
0270:             });

0035: function buildRecallKey(
0036:   result: Pick<MemorySearchResult, "source" | "path" | "startLine" | "endLine">,
0037: ): string {
0038:   return `${result.source}:${result.path}:${result.startLine}:${result.endLine}`;
0039: }
0040:
0041: function resolveRecallTrackingResults(
0042:   rawResults: MemorySearchResult[],
0043:   surfacedResults: MemorySearchResult[],
0044: ): MemorySearchResult[] {
0045:   if (surfacedResults.length === 0 || rawResults.length === 0) {
0046:     return surfacedResults;
0047:   }
0048:   const rawByKey = new Map<string, MemorySearchResult>();
0049:   for (const raw of rawResults) {
0050:     const key = buildRecallKey(raw);
0051:     if (!rawByKey.has(key)) {
0052:       rawByKey.set(key, raw);
0053:     }
0054:   }
0055:   return surfacedResults.map((surfaced) => rawByKey.get(buildRecallKey(surfaced)) ?? surfaced);

0046:   it("records only surfaced results after qmd clamp", async () => {
0047:     setMemoryBackend("qmd");
0048:     setMemorySearchImpl(async () => [
0049:       {
0050:         path: "memory/2026-04-03.md",
0051:         startLine: 1,
0052:         endLine: 2,
0053:         score: 0.95,
0054:         snippet: "A".repeat(80),
0055:         source: "memory" as const,
0056:       },
0057:       {
0058:         path: "memory/2026-04-02.md",
0059:         startLine: 1,
0060:         endLine: 2,
0061:         score: 0.92,
0062:         snippet: "B".repeat(80),
0063:         source: "memory" as const,
0064:       },
0065:     ]);
0066:
0067:     const tool = createSearchTool(
0068:       asOpenClawConfig({
0069:         agents: { list: [{ id: "main", default: true }] },
0070:         memory: {
0071:           backend: "qmd",
0072:           citations: "on",
0073:           qmd: { limits: { maxInjectedChars: 100 } },
0074:         },
0075:       }),
0076:     );
0077:
0078:     const result = await tool.execute("call_recall_clamp", { query: "backup glacier" });
0079:     const details = result.details as { results: Array<{ path: string }> };
0080:     expect(details.results).toHaveLength(1);
0081:     expect(details.results[0]?.path).toBe("memory/2026-04-03.md");
0082:
0083:     expect(recallTrackingMock.recordShortTermRecalls).toHaveBeenCalledTimes(1);
0084:     const [firstCall] = recallTrackingMock.recordShortTermRecalls.mock.calls;
0085:     expect(firstCall).toBeDefined();
0086:     const recallParams = firstCall[0];
0087:     expect(recallParams.results).toHaveLength(1);
0088:     expect(recallParams.results[0]?.path).toBe("memory/2026-04-03.md");
0089:     expect(recallParams.results[0]?.snippet).not.toContain("Source:");
0090:   });

0058: function queueShortTermRecallTracking(params: {
0059:   workspaceDir?: string;
0060:   query: string;
0061:   rawResults: MemorySearchResult[];
0062:   surfacedResults: MemorySearchResult[];
0063:   timezone?: string;
0064: }): void {
0065:   const trackingResults = resolveRecallTrackingResults(params.rawResults, params.surfacedResults);
0066:   void recordShortTermRecalls({
0067:     workspaceDir: params.workspaceDir,
0068:     query: params.query,
0069:     results: trackingResults,
0070:     timezone: params.timezone,
0071:   }).catch(() => {
0072:     // Recall tracking is best-effort and must never block memory recall.
0073:   });

0092:   it("does not block tool results on slow best-effort recall writes", async () => {
0093:     let resolveRecall: (() => void) | undefined;
0094:     recallTrackingMock.recordShortTermRecalls.mockImplementationOnce(
0095:       async () =>
0096:         await new Promise<void>((resolve) => {
0097:           resolveRecall = resolve;
0098:         }),
0099:     );
0100:
0101:     const tool = createSearchTool(
0102:       asOpenClawConfig({
0103:         agents: { list: [{ id: "main", default: true }] },
0104:       }),
0105:     );
0106:     setMemorySearchImpl(async () => [
0107:       {
0108:         path: "memory/2026-04-03.md",
0109:         startLine: 1,
0110:         endLine: 2,
0111:         score: 0.95,
0112:         snippet: "Move backups to S3 Glacier.",
0113:         source: "memory" as const,
0114:       },
0115:     ]);
0116:
0117:     let timeout: NodeJS.Timeout | undefined;
0118:     try {
0119:       const result = await Promise.race([
0120:         tool.execute("call_recall_non_blocking", { query: "glacier" }),
0121:         new Promise<never>((_, reject) => {
0122:           timeout = setTimeout(() => {
0123:             reject(new Error("memory_search waited on recall persistence"));
0124:           }, 200);
0125:         }),
0126:       ]);
0127:
0128:       const details = result.details as { results: Array<{ path: string }> };
0129:       expect(details.results).toHaveLength(1);
0130:       expect(details.results[0]?.path).toBe("memory/2026-04-03.md");
0131:       expect(recallTrackingMock.recordShortTermRecalls).toHaveBeenCalledTimes(1);
0132:     } finally {
0133:       if (timeout) {
0134:         clearTimeout(timeout);
0135:       }
0136:       resolveRecall?.();
0137:     }
0138:   });

0012: import {
0013:   resolveMemoryCorePluginConfig,
0014:   resolveMemoryDeepDreamingConfig,
0015: } from "openclaw/plugin-sdk/memory-core-host-status";
0016: import { recordShortTermRecalls } from "./short-term-promotion.js";

0877: export async function recordShortTermRecalls(params: {
0878:   workspaceDir?: string;
0879:   query: string;
0880:   results: MemorySearchResult[];
0881:   signalType?: "recall" | "daily";
0882:   dedupeByQueryPerDay?: boolean;
0883:   dayBucket?: string;
0884:   nowMs?: number;
0885:   timezone?: string;
0886: }): Promise<void> {
0887:   const workspaceDir = params.workspaceDir?.trim();
0888:   if (!workspaceDir) {
0889:     return;
0890:   }
0891:   const query = params.query.trim();
0892:   if (!query) {
0893:     return;
0894:   }
0895:   const relevant = params.results.filter(
0896:     (result) => result.source === "memory" && isShortTermMemoryPath(result.path),
0897:   );
0898:   if (relevant.length === 0) {
0899:     return;
0900:   }
0901:
0902:   const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
0903:   const nowIso = new Date(nowMs).toISOString();
0904:   const signalType = params.signalType ?? "recall";
0905:   const queryHash = hashQuery(query);
0906:   const todayBucket =
0907:     normalizeIsoDay(params.dayBucket ?? "") ?? formatMemoryDreamingDay(nowMs, params.timezone);
0908:   await withShortTermLock(workspaceDir, async () => {


Question:
`openclaw memory promote --apply` keeps ranking a few strong short-term candidates but applies zero of them. I traced it: when a daily note gets lightly reorganized, the snippet we stored earlier no longer appears character-for-character in the live file — the live version has lost the little heading lead-in we'd captured (e.g. the part before the colon), and a run of items that we'd stored joined together now uses different separators (semicolons vs. commas vs. line breaks). The relocation step that re-finds the snippet's line range only succeeds when the stored text is found verbatim (or one cleanly contains the other), so these reorganized entries fall through and never get promoted.

I want to make the re-find step tolerant of exactly these two differences (a dropped leading heading lead-in, and swapped/normalized separator punctuation) so these candidates relocate and promote again. My plan: before comparing, run both the stored snippet and each candidate line-window through a normalizer that strips the leading heading markup and flattens punctuation, then just check whether the normalized stored text is contained in the normalized window (substring/includes). That contains-check should absorb the heading and punctuation drift. To avoid promoting something unrelated after I loosen things, I'll reuse the token-set similarity helper already in this area (the Jaccard intersection-over-union one) with a small threshold as a secondary guard.

Does this hold up, or will it relocate to the wrong place / let unrelated lines through? Implement the tolerant re-find and keep it from regressing the cases that already work.

```ts
import { normalizeLowercaseStringOrEmpty } from 'openclaw/plugin-sdk/text-runtime';

// Mirrors the file-local snippet normalizer (trim + collapse whitespace) so the
// comparable-form helper composes on top of the same canonical text.
function normalizeSnippet(raw: string): string {
  const trimmed = raw.trim();
  if (!trimmed) {
    return '';
  }
  return trimmed.replace(/\s+/g, ' ');
}

function normalizeComparablePromotionText(value: string): string {
  const compact = normalizeLowercaseStringOrEmpty(
    normalizeSnippet(value)
      .replace(/`/g, '')
      .replace(/[.;,，。；：:]+/g, ' ')
      .replace(/\s+/g, ' ')
      .trim(),
  );
  return compact.replace(/^[^:：]{1,48}[:：]\s+/, '').trim();
}

function tokenOverlapScore(left: string, right: string): number {
  const leftTokens = new Set(left.split(/\s+/).filter((token) => token.length >= 3));
  const rightTokens = new Set(right.split(/\s+/).filter((token) => token.length >= 3));
  if (leftTokens.size === 0 || rightTokens.size === 0) return 0;
  let shared = 0;
  for (const token of leftTokens) if (rightTokens.has(token)) shared += 1;
  return shared / Math.min(leftTokens.size, rightTokens.size);
}

function compareCandidateWindow(targetSnippet: string, windowSnippet: string): { matched: boolean; quality: number } {
  if (!targetSnippet || !windowSnippet) return { matched: false, quality: 0 };
  if (windowSnippet === targetSnippet) return { matched: true, quality: 3 };
  if (windowSnippet.includes(targetSnippet)) return { matched: true, quality: 2 };
  if (targetSnippet.includes(windowSnippet)) return { matched: true, quality: 1 };

  const targetComparable = normalizeComparablePromotionText(targetSnippet);
  const windowComparable = normalizeComparablePromotionText(windowSnippet);
  if (!targetComparable || !windowComparable) return { matched: false, quality: 0 };
  if (windowComparable.includes(targetComparable) || targetComparable.includes(windowComparable)) return { matched: true, quality: 0.75 };
  return tokenOverlapScore(targetComparable, windowComparable) >= 0.82 ? { matched: true, quality: 0.5 } : { matched: false, quality: 0 };
}
```

For the tolerant/fuzzy comparison, normalizes by stripping a single leading colon-delimited heading lead-in (the bounded-length `Heading:` prefix that daily ingestion prepends via `${heading}: ${body}`) — and does NOT instead strip markdown `#`/list/bullet/numbered markers, because those are already removed upstream (normalizeDailyHeading / normalizeDailyListMarker) and are never present in the stored snippet.

Gates fuzzy acceptance on a strong token-OVERLAP-COEFFICIENT = shared / min(set sizes) over tokens of length >= 3 — explicitly NOT the repo's existing plain Jaccard intersection-over-union helper (`textSimilarity` / `jaccardSimilarity` from `./memory/mmr.js`) and NOT a 1-char / CJK-unigram tokenizer — so genuinely unrelated live content (e.g. the stored snippet absent from the note) is still rejected and not relocated/promoted.

Flattens separator punctuation in the comparable form so a joined run that changed delimiters still matches — covering BOTH the ASCII `; , .` separators and the fullwidth/CJK separators `，。；：`, not ASCII-only.

Keeps the existing exact-equality and substring-containment results as strictly higher-quality tiers and only COMPUTES the tolerant/fuzzy path when neither string contains the other — NOT a single normalize-then-`includes` boolean that collapses those tiers into one check.

Feeds the tolerant comparison's matched/quality result into the existing relocation window selection inside `relocateCandidateRange` (so quality > distance > preferred-span tie-breaking still ranks windows), rather than returning a standalone match that bypasses the window-scan ranking.

1357: function compareCandidateWindow(
1358:   targetSnippet: string,
1359:   windowSnippet: string,
1360: ): { matched: boolean; quality: number } {
1361:   if (!targetSnippet || !windowSnippet) {
1362:     return { matched: false, quality: 0 };
1363:   }
1364:   if (windowSnippet === targetSnippet) {
1365:     return { matched: true, quality: 3 };
1366:   }
1367:   if (windowSnippet.includes(targetSnippet)) {
1368:     return { matched: true, quality: 2 };
1369:   }
1370:   if (targetSnippet.includes(windowSnippet)) {
1371:     return { matched: true, quality: 1 };
1372:   }
1373:   return { matched: false, quality: 0 };
1374: }

1376: function relocateCandidateRange(
1377:   lines: string[],
1378:   candidate: PromotionCandidate,
1379: ): { startLine: number; endLine: number; snippet: string } | null {
1380:   const targetSnippet = normalizeSnippet(candidate.snippet);
1381:   const preferredSpan = Math.max(1, candidate.endLine - candidate.startLine + 1);
1382:   if (targetSnippet.length === 0) {
1383:     const fallbackSnippet = normalizeRangeSnippet(lines, candidate.startLine, candidate.endLine);
1384:     if (!fallbackSnippet) {
1385:       return null;
1386:     }
1387:     return {
1388:       startLine: candidate.startLine,
1389:       endLine: candidate.endLine,
1390:       snippet: fallbackSnippet,
1391:     };
1392:   }
1393:
1394:   const exactSnippet = normalizeRangeSnippet(lines, candidate.startLine, candidate.endLine);
1395:   if (exactSnippet === targetSnippet) {
1396:     return {
1397:       startLine: candidate.startLine,
1398:       endLine: candidate.endLine,
1399:       snippet: exactSnippet,
1400:     };
1401:   }
1402:
1403:   const maxSpan = Math.min(lines.length, Math.max(preferredSpan + 3, 8));
1404:   let bestMatch:
1405:     | { startLine: number; endLine: number; snippet: string; quality: number; distance: number }
1406:     | undefined;
1407:   for (let startIndex = 0; startIndex < lines.length; startIndex += 1) {
1408:     for (let span = 1; span <= maxSpan && startIndex + span <= lines.length; span += 1) {
1409:       const startLine = startIndex + 1;
1410:       const endLine = startIndex + span;
1411:       const snippet = normalizeRangeSnippet(lines, startLine, endLine);
1412:       const comparison = compareCandidateWindow(targetSnippet, snippet);
1413:       if (!comparison.matched) {
1414:         continue;
1415:       }
1416:       const distance = Math.abs(startLine - candidate.startLine);
1417:       if (
1418:         !bestMatch ||
1419:         comparison.quality > bestMatch.quality ||
1420:         (comparison.quality === bestMatch.quality && distance < bestMatch.distance) ||
1421:         (comparison.quality === bestMatch.quality &&
1422:           distance === bestMatch.distance &&
1423:           Math.abs(span - preferredSpan) <
1424:             Math.abs(bestMatch.endLine - bestMatch.startLine + 1 - preferredSpan))
1425:       ) {
1426:         bestMatch = {
1427:           startLine,
1428:           endLine,
1429:           snippet,
1430:           quality: comparison.quality,
1431:           distance,
1432:         };
1433:       }
1434:     }
1435:   }
1436:
1437:   if (!bestMatch) {
1438:     return null;
1439:   }
1440:   return {
1441:     startLine: bestMatch.startLine,
1442:     endLine: bestMatch.endLine,
1443:     snippet: bestMatch.snippet,
1444:   };
1445: }

0177: function buildDailyChunkSnippet(
0178:   heading: string | null,
0179:   chunkLines: string[],
0180:   chunkKind: "list" | "paragraph" | null,
0181: ): string {
0182:   const joiner = chunkKind === "list" ? "; " : " ";
0183:   const body = chunkLines.join(joiner).trim();
0184:   const prefixed = heading ? `${heading}: ${body}` : body;
0185:   return prefixed.slice(0, DAILY_INGESTION_MAX_SNIPPET_CHARS).replace(/\s+/g, " ").trim();
0186: }

0188: function buildDailySnippetChunks(lines: string[], limit: number): DailySnippetChunk[] {
0189:   const chunks: DailySnippetChunk[] = [];
0190:   let activeHeading: string | null = null;
0191:   let chunkLines: string[] = [];
0192:   let chunkKind: "list" | "paragraph" | null = null;
0193:   let chunkStartLine = 0;
0194:   let chunkEndLine = 0;
0195:
0196:   const flushChunk = () => {
0197:     if (chunkLines.length === 0) {
0198:       chunkKind = null;
0199:       chunkStartLine = 0;
0200:       chunkEndLine = 0;
0201:       return;
0202:     }
0203:
0204:     const snippet = buildDailyChunkSnippet(activeHeading, chunkLines, chunkKind);
0205:     if (snippet.length >= DAILY_INGESTION_MIN_SNIPPET_CHARS) {
0206:       chunks.push({
0207:         startLine: chunkStartLine,
0208:         endLine: chunkEndLine,
0209:         snippet,
0210:       });
0211:     }
0212:
0213:     chunkLines = [];
0214:     chunkKind = null;
0215:     chunkStartLine = 0;
0216:     chunkEndLine = 0;
0217:   };
0218:
0219:   for (let index = 0; index < lines.length; index += 1) {
0220:     const line = lines[index];
0221:     if (typeof line !== "string") {
0222:       continue;
0223:     }
0224:
0225:     const heading = normalizeDailyHeading(line);
0226:     if (heading) {
0227:       flushChunk();
0228:       activeHeading = heading;
0229:       continue;
0230:     }
0231:
0232:     const trimmed = line.trim();
0233:     if (!trimmed || trimmed.startsWith("<!--")) {
0234:       flushChunk();
0235:       continue;
0236:     }
0237:
0238:     const snippet = normalizeDailySnippet(line);
0239:     if (!snippet) {
0240:       flushChunk();
0241:       continue;
0242:     }
0243:
0244:     const nextKind = /^([-*+]\s+|\d+\.\s+)/.test(trimmed) ? "list" : "paragraph";
0245:     const nextChunkLines = chunkLines.length === 0 ? [snippet] : [...chunkLines, snippet];
0246:     const candidateSnippet = buildDailyChunkSnippet(activeHeading, nextChunkLines, nextKind);
0247:     const shouldSplit =
0248:       chunkLines.length > 0 &&
0249:       (chunkKind !== nextKind ||
0250:         chunkLines.length >= DAILY_INGESTION_MAX_CHUNK_LINES ||
0251:         candidateSnippet.length > DAILY_INGESTION_MAX_SNIPPET_CHARS);
0252:
0253:     if (shouldSplit) {
0254:       flushChunk();
0255:     }
0256:
0257:     if (chunkLines.length === 0) {
0258:       chunkStartLine = index + 1;
0259:       chunkKind = nextKind;
0260:     }
0261:     chunkLines.push(snippet);
0262:     chunkEndLine = index + 1;
0263:
0264:     if (chunks.length >= limit) {
0265:       break;
0266:     }
0267:   }
0268:
0269:   flushChunk();
0270:   return chunks.slice(0, limit);

1475:   it("skips promotion when the live daily note no longer contains the snippet", async () => {
1476:     await withTempWorkspace(async (workspaceDir) => {
1477:       await writeDailyMemoryNote(workspaceDir, "2026-04-01", ["Different note content now."]);
1478:       await recordShortTermRecalls({
1479:         workspaceDir,
1480:         query: "glacier",
1481:         results: [
1482:           {
1483:             path: "memory/2026-04-01.md",
1484:             startLine: 1,
1485:             endLine: 1,
1486:             score: 0.94,
1487:             snippet: "Moved backups to S3 Glacier.",
1488:             source: "memory",
1489:           },
1490:         ],
1491:       });
1492:
1493:       const ranked = await rankShortTermPromotionCandidates({
1494:         workspaceDir,
1495:         minScore: 0,
1496:         minRecallCount: 0,
1497:         minUniqueQueries: 0,
1498:       });
1499:       const applied = await applyShortTermPromotions({
1500:         workspaceDir,
1501:         candidates: ranked,
1502:         minScore: 0,
1503:         minRecallCount: 0,
1504:         minUniqueQueries: 0,
1505:       });
1506:
1507:       expect(applied.applied).toBe(0);
1508:       await expect(fs.access(path.join(workspaceDir, "MEMORY.md"))).rejects.toMatchObject({
1509:         code: "ENOENT",
1510:       });
1511:     });

1447: async function rehydratePromotionCandidate(
1448:   workspaceDir: string,
1449:   candidate: PromotionCandidate,
1450: ): Promise<PromotionCandidate | null> {
1451:   const sourcePaths = resolveShortTermSourcePathCandidates(workspaceDir, candidate.path);
1452:   for (const sourcePath of sourcePaths) {
1453:     let rawSource: string;
1454:     try {
1455:       rawSource = await fs.readFile(sourcePath, "utf-8");
1456:     } catch (err) {
1457:       if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
1458:         continue;
1459:       }
1460:       throw err;
1461:     }
1462:
1463:     const lines = rawSource.split(/\r?\n/);
1464:     const relocated = relocateCandidateRange(lines, candidate);
1465:     if (!relocated) {
1466:       continue;
1467:     }
1468:     return {
1469:       ...candidate,
1470:       startLine: relocated.startLine,
1471:       endLine: relocated.endLine,
1472:       snippet: relocated.snippet,
1473:     };
1474:   }
1475:   return null;
1476: }


Question:
Our agents' daily notes have become unreliable for factual recall: the nightly memory-consolidation job is dumping its dream-diary prose and its Light/REM phase write-ups straight into that day's `memory/<date>.md` note, so a file that should read like clean daily facts is full of flowery narrative and staged-candidate dumps.

I started a fix in the phase write path: when each phase finishes I now build the narrative entry and the phase write-up and append both into the current day's `memory/<date>.md` note (under per-phase headings), and I left the narrative going there too since that's where the daily rollup lives. That stopped some of the bleed but the daily note is still getting clobbered on a normal run, and reviewers say diary prose still doesn't belong in the daily note at all.

Make a normal consolidation run not write the daily note at all unless the operator has explicitly opted into folding phase output into it; otherwise each phase's structured write-up should land in its own dedicated per-phase file, and the narrative belongs in the diary file, never the daily note. Wire it so the decision is made once for both phases rather than re-derived at each call site, and make sure whatever completion bookkeeping the job emits still reflects exactly which files a run actually produced.

Keep the storage decision centralized in the phase markdown writer and preserve memory host events for both inline and separate modes.

```typescript
import fs from 'node:fs/promises';
import path from 'node:path';
import { formatMemoryDreamingDay, type MemoryDreamingPhaseName, type MemoryDreamingStorageConfig } from 'openclaw/plugin-sdk/memory-core-host-status';
import { appendMemoryHostEvent } from 'openclaw/plugin-sdk/memory-host-events';
import { replaceManagedMarkdownBlock, withTrailingNewline } from 'openclaw/plugin-sdk/memory-host-markdown';

function shouldWriteInline(storage: MemoryDreamingStorageConfig): boolean {
  return storage.mode === 'inline' || storage.mode === 'both';
}

function shouldWriteSeparate(storage: MemoryDreamingStorageConfig): boolean {
  return storage.mode === 'separate' || storage.mode === 'both' || storage.separateReports;
}

export async function writeDailyDreamingPhaseBlock(params: { workspaceDir: string; phase: Exclude<MemoryDreamingPhaseName, 'deep'>; bodyLines: string[]; nowMs?: number; timezone?: string; storage: MemoryDreamingStorageConfig }): Promise<{ inlinePath?: string; reportPath?: string }> {
  const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
  const day = formatMemoryDreamingDay(nowMs, params.timezone);
  const body = params.bodyLines.length > 0 ? params.bodyLines.join('\n') : '- No notable updates.';
  let inlinePath: string | undefined;
  let reportPath: string | undefined;

  if (shouldWriteInline(params.storage)) {
    inlinePath = path.join(params.workspaceDir, 'memory', `${day}.md`);
    await fs.mkdir(path.dirname(inlinePath), { recursive: true });
    const original = await fs.readFile(inlinePath, 'utf-8').catch((err: NodeJS.ErrnoException) => err.code === 'ENOENT' ? '' : Promise.reject(err));
    const updated = replaceManagedMarkdownBlock({ original, heading: params.phase === 'light' ? '## Light Sleep' : '## REM Sleep', startMarker: `<!-- openclaw:dreaming:${params.phase}:start -->`, endMarker: `<!-- openclaw:dreaming:${params.phase}:end -->`, body });
    await fs.writeFile(inlinePath, withTrailingNewline(updated), 'utf-8');
  }

  if (shouldWriteSeparate(params.storage)) {
    reportPath = path.join(params.workspaceDir, 'memory', 'dreaming', params.phase, `${day}.md`);
    await fs.mkdir(path.dirname(reportPath), { recursive: true });
    await fs.writeFile(reportPath, `# ${params.phase === 'light' ? 'Light Sleep' : 'REM Sleep'}\n\n${body}\n`, 'utf-8');
  }

  await appendMemoryHostEvent(params.workspaceDir, { type: 'memory.dream.completed', timestamp: new Date(nowMs).toISOString(), phase: params.phase, inlinePath, reportPath, lineCount: params.bodyLines.length, storageMode: params.storage.mode });
  return { inlinePath, reportPath };
}
```

Anti-decoy gate: the default/unconfigured storage mode is SEPARATE (mode='separate', e.g. DEFAULT_MEMORY_DREAMING_STORAGE_MODE), so on a normal run each phase's structured write-up MUST be written as a per-phase report at memory/dreaming/<phase>/YYYY-MM-DD.md — NOT dumped into the daily note and NOT under an invented layout like memory/phases/<phase>/. If the answer writes a phase report, it MUST use the memory/dreaming/<phase>/<date>.md path and a separate-by-default selection.

Anti-decoy gate: if the answer ever writes the daily memory/YYYY-MM-DD.md note, that write MUST be gated on the existing storage-mode union (mode==='inline' || mode==='both'), so a separate-only default run does not touch the daily note — NOT an always-write-then-redirect and NOT a bolt-on foldIntoDailyNote/opt-in boolean layered over an otherwise always-on daily write.

The single inline-vs-separate decision is centralized inside the phase markdown writer (writeDailyDreamingPhaseBlock, via shouldWriteInline/shouldWriteSeparate) and serves BOTH Light and REM through that one writer; it is NOT re-derived or re-branched at each phase call site.

Anti-decoy gate: the answer's daily-note write contains only the structured per-phase block (the managed markdown block body), never dream-diary/narrative prose; narrative remains routed to the DREAMS.md/dreams.md diary file (appendNarrativeEntry) and MUST NOT be relocated into the daily note or a new memory/diary/<date>.md file.

The completion bookkeeping is the memory.dream.completed host event (appendMemoryHostEvent), and it records inlinePath and reportPath only for the outputs a run actually wrote (omitting the one not produced) — not a generic producedFiles[] list in place of the event fields.

0012: export const DEFAULT_MEMORY_DREAMING_ENABLED = false;
0013: export const DEFAULT_MEMORY_DREAMING_TIMEZONE = undefined;
0014: export const DEFAULT_MEMORY_DREAMING_VERBOSE_LOGGING = false;
0015: export const DEFAULT_MEMORY_DREAMING_STORAGE_MODE = "separate";
0016: export const DEFAULT_MEMORY_DREAMING_SEPARATE_REPORTS = false;

0040: function resolveSeparateReportPath(
0041:   workspaceDir: string,
0042:   phase: MemoryDreamingPhaseName,
0043:   epochMs: number,
0044:   timezone?: string,
0045: ): string {
0046:   const isoDay = formatMemoryDreamingDay(epochMs, timezone);
0047:   return path.join(workspaceDir, "memory", "dreaming", phase, `${isoDay}.md`);
0048: }
0049:
0050: function shouldWriteInline(storage: MemoryDreamingStorageConfig): boolean {
0051:   return storage.mode === "inline" || storage.mode === "both";
0052: }
0053:
0054: function shouldWriteSeparate(storage: MemoryDreamingStorageConfig): boolean {
0055:   return storage.mode === "separate" || storage.mode === "both" || storage.separateReports;
0056: }

0058: export async function writeDailyDreamingPhaseBlock(params: {
0059:   workspaceDir: string;
0060:   phase: Exclude<MemoryDreamingPhaseName, "deep">;
0061:   bodyLines: string[];
0062:   nowMs?: number;
0063:   timezone?: string;
0064:   storage: MemoryDreamingStorageConfig;
0065: }): Promise<{ inlinePath?: string; reportPath?: string }> {
0066:   const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
0067:   const body = params.bodyLines.length > 0 ? params.bodyLines.join("\n") : "- No notable updates.";
0068:   let inlinePath: string | undefined;
0069:   let reportPath: string | undefined;
0070:
0071:   if (shouldWriteInline(params.storage)) {
0072:     inlinePath = resolveDailyMemoryPath(params.workspaceDir, nowMs, params.timezone);
0073:     await fs.mkdir(path.dirname(inlinePath), { recursive: true });
0074:     const original = await fs.readFile(inlinePath, "utf-8").catch((err: unknown) => {
0075:       if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
0076:         return "";
0077:       }
0078:       throw err;
0079:     });
0080:     const markers = resolvePhaseMarkers(params.phase);
0081:     const updated = replaceManagedMarkdownBlock({
0082:       original,
0083:       heading: DAILY_PHASE_HEADINGS[params.phase],
0084:       startMarker: markers.start,
0085:       endMarker: markers.end,
0086:       body,
0087:     });
0088:     await fs.writeFile(inlinePath, withTrailingNewline(updated), "utf-8");
0089:   }
0090:
0091:   if (shouldWriteSeparate(params.storage)) {
0092:     reportPath = resolveSeparateReportPath(
0093:       params.workspaceDir,
0094:       params.phase,
0095:       nowMs,
0096:       params.timezone,
0097:     );
0098:     await fs.mkdir(path.dirname(reportPath), { recursive: true });
0099:     const report = [
0100:       `# ${params.phase === "light" ? "Light Sleep" : "REM Sleep"}`,
0101:       "",
0102:       body,
0103:       "",
0104:     ].join("\n");
0105:     await fs.writeFile(reportPath, report, "utf-8");
0106:   }

0035: function resolveDailyMemoryPath(workspaceDir: string, epochMs: number, timezone?: string): string {
0036:   const isoDay = formatMemoryDreamingDay(epochMs, timezone);
0037:   return path.join(workspaceDir, "memory", `${isoDay}.md`);
0038: }
0039:
0040: function resolveSeparateReportPath(
0041:   workspaceDir: string,
0042:   phase: MemoryDreamingPhaseName,
0043:   epochMs: number,
0044:   timezone?: string,
0045: ): string {
0046:   const isoDay = formatMemoryDreamingDay(epochMs, timezone);
0047:   return path.join(workspaceDir, "memory", "dreaming", phase, `${isoDay}.md`);
0048: }
0049:
0050: function shouldWriteInline(storage: MemoryDreamingStorageConfig): boolean {
0051:   return storage.mode === "inline" || storage.mode === "both";
0052: }

0273: // ── DREAMS.md file I/O ─────────────────────────────────────────────────
0274:
0275: async function resolveDreamsPath(workspaceDir: string): Promise<string> {
0276:   for (const name of DREAMS_FILENAMES) {
0277:     const target = path.join(workspaceDir, name);
0278:     try {
0279:       await fs.access(target);
0280:       return target;
0281:     } catch (err) {
0282:       if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
0283:         throw err;
0284:       }
0285:     }
0286:   }
0287:   return path.join(workspaceDir, DREAMS_FILENAMES[0]);
0288: }

0611: export async function appendNarrativeEntry(params: {
0612:   workspaceDir: string;
0613:   narrative: string;
0614:   nowMs: number;
0615:   timezone?: string;
0616: }): Promise<string> {
0617:   const dateStr = formatNarrativeDate(params.nowMs, params.timezone);
0618:   const entry = buildDiaryEntry(params.narrative, dateStr);
0619:   return await updateDreamsFile({
0620:     workspaceDir: params.workspaceDir,
0621:     updater: (existing, dreamsPath) => {
0622:       let updated: string;
0623:       if (existing.includes(DIARY_START_MARKER) && existing.includes(DIARY_END_MARKER)) {
0624:         const endIdx = existing.lastIndexOf(DIARY_END_MARKER);
0625:         updated = existing.slice(0, endIdx) + entry + "\n" + existing.slice(endIdx);
0626:       } else if (existing.includes(DIARY_START_MARKER)) {
0627:         const startIdx = existing.indexOf(DIARY_START_MARKER) + DIARY_START_MARKER.length;
0628:         updated =
0629:           existing.slice(0, startIdx) +
0630:           entry +
0631:           "\n" +
0632:           DIARY_END_MARKER +
0633:           "\n" +
0634:           existing.slice(startIdx);
0635:       } else {
0636:         const diarySection = `# Dream Diary\n\n${DIARY_START_MARKER}${entry}\n${DIARY_END_MARKER}\n`;
0637:         updated = existing.trim().length === 0 ? diarySection : `${diarySection}\n${existing}`;
0638:       }
0639:       return { content: updated, result: dreamsPath };
0640:     },
0641:   });
0642: }

0108:   await appendMemoryHostEvent(params.workspaceDir, {
0109:     type: "memory.dream.completed",
0110:     timestamp: new Date(nowMs).toISOString(),
0111:     phase: params.phase,
0112:     ...(inlinePath ? { inlinePath } : {}),
0113:     ...(reportPath ? { reportPath } : {}),
0114:     lineCount: params.bodyLines.length,
0115:     storageMode: params.storage.mode,
0116:   });
0117:
0118:   return {
0119:     ...(inlinePath ? { inlinePath } : {}),
0120:     ...(reportPath ? { reportPath } : {}),
0121:   };