[Feat] Gateway: add doctor.memory.remHarness probe (#66673)

Merged via squash.

Prepared head SHA: c19e6a335a
Co-authored-by: samzong <13782141+samzong@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
This commit is contained in:
samzong
2026-04-29 13:23:36 +08:00
committed by GitHub
parent 364c67bcb5
commit 450607847b
14 changed files with 1055 additions and 68 deletions

View File

@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
- Active Memory: add optional per-conversation `allowedChatIds` and `deniedChatIds` filters so operators can enable recall only for selected direct, group, or channel conversations while keeping broad sessions skipped. (#67977) Thanks @quengh.
- Active Memory: return bounded partial recall summaries when the hidden memory sub-agent times out, including the default temporary-transcript path, so useful recovered context is not discarded. (#73219) Thanks @joeykrug.
- Docker setup: add `OPENCLAW_SKIP_ONBOARDING` so automated Docker installs can skip the interactive onboarding step while still applying gateway defaults. (#55518) Thanks @jinjimz.
- Gateway/memory: add a read-only `doctor.memory.remHarness` RPC so operator clients can preview bounded REM dreaming output without running mutation paths. (#66673) Thanks @samzong.
### Fixes

View File

@@ -327,6 +327,7 @@ enumeration of `src/gateway/server-methods/*.ts`.
- `usage.status` returns provider usage windows/remaining quota summaries.
- `usage.cost` returns aggregated cost usage summaries for a date range.
- `doctor.memory.status` returns vector-memory / cached embedding readiness for the active default agent workspace. Pass `{ "probe": true }` or `{ "deep": true }` only when the caller explicitly wants a live embedding provider ping.
- `doctor.memory.remHarness` returns a bounded, read-only REM harness preview for remote control-plane clients. It can include workspace paths, memory snippets, rendered grounded markdown, and deep promotion candidates, so callers need `operator.read`.
- `sessions.usage` returns per-session usage summaries.
- `sessions.usage.timeseries` returns timeseries usage for one session.
- `sessions.usage.logs` returns usage log entries for one session.

View File

@@ -10,3 +10,6 @@ export {
writeBackfillDiaryEntries,
} from "./src/dreaming-narrative.js";
export { previewGroundedRemMarkdown } from "./src/rem-evidence.js";
export { filterRecallEntriesWithinLookback } from "./src/dreaming-phases.js";
export { previewRemHarness } from "./src/rem-harness.js";
export type { PreviewRemHarnessOptions, PreviewRemHarnessResult } from "./src/rem-harness.js";

View File

@@ -37,7 +37,7 @@ import type {
MemorySearchCommandOptions,
} from "./cli.types.js";
import { removeBackfillDiaryEntries, writeBackfillDiaryEntries } from "./dreaming-narrative.js";
import { previewRemDreaming, seedHistoricalDailyMemorySignals } from "./dreaming-phases.js";
import { seedHistoricalDailyMemorySignals } from "./dreaming-phases.js";
import {
auditDreamingArtifacts,
repairDreamingArtifacts,
@@ -47,12 +47,12 @@ import {
import { asRecord } from "./dreaming-shared.js";
import { resolveShortTermPromotionDreamingConfig } from "./dreaming.js";
import { previewGroundedRemMarkdown } from "./rem-evidence.js";
import { previewRemHarness } from "./rem-harness.js";
import {
applyShortTermPromotions,
auditShortTermPromotionArtifacts,
removeGroundedShortTermCandidates,
repairShortTermPromotionArtifacts,
readShortTermRecallEntries,
recordGroundedShortTermCandidates,
recordShortTermRecalls,
rankShortTermPromotionCandidates,
@@ -194,22 +194,6 @@ async function createHistoricalRemHarnessWorkspace(params: {
};
}
async function listWorkspaceDailyFiles(workspaceDir: string, limit: number): Promise<string[]> {
const memoryDir = path.join(workspaceDir, "memory");
try {
const files = await listHistoricalDailyFiles(memoryDir);
if (!Number.isFinite(limit) || limit <= 0 || files.length <= limit) {
return files;
}
return files.slice(-limit);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return [];
}
throw err;
}
}
function formatDreamingSummary(cfg: OpenClawConfig): string {
const pluginConfig = resolveMemoryPluginConfig(cfg);
const dreaming = resolveShortTermPromotionDreamingConfig({ pluginConfig, cfg });
@@ -1535,10 +1519,6 @@ export async function runMemoryRemHarness(opts: MemoryRemHarnessOptions) {
const status = manager.status();
const managerWorkspaceDir = status.workspaceDir?.trim();
const pluginConfig = resolveMemoryPluginConfig(cfg);
const deep = resolveShortTermPromotionDreamingConfig({
pluginConfig,
cfg,
});
if (!managerWorkspaceDir && !opts.path) {
defaultRuntime.error("Memory rem-harness requires a resolvable workspace directory.");
process.exitCode = 1;
@@ -1585,34 +1565,19 @@ export async function runMemoryRemHarness(opts: MemoryRemHarnessOptions) {
return;
}
try {
if (groundedInputPaths.length === 0 && opts.grounded) {
groundedInputPaths = await listWorkspaceDailyFiles(workspaceDir, remConfig.limit);
}
const cutoffMs = nowMs - Math.max(0, remConfig.lookbackDays) * 24 * 60 * 60 * 1000;
const recallEntries = (await readShortTermRecallEntries({ workspaceDir, nowMs })).filter(
(entry) => Date.parse(entry.lastRecalledAt) >= cutoffMs,
);
const remPreview = previewRemDreaming({
entries: recallEntries,
limit: remConfig.limit,
minPatternStrength: remConfig.minPatternStrength,
});
const groundedPreview =
opts.grounded && groundedInputPaths.length > 0
? await previewGroundedRemMarkdown({
workspaceDir,
inputPaths: groundedInputPaths,
})
: null;
const deepCandidates = await rankShortTermPromotionCandidates({
const preview = await previewRemHarness({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
cfg,
pluginConfig,
grounded: Boolean(opts.grounded),
groundedInputPaths,
includePromoted: Boolean(opts.includePromoted),
recencyHalfLifeDays: deep.recencyHalfLifeDays,
maxAgeDays: deep.maxAgeDays,
nowMs,
});
groundedInputPaths = preview.groundedInputPaths;
const remPreview = preview.rem;
const groundedPreview = preview.grounded;
const deepCandidates = preview.deep.candidates;
if (opts.json) {
defaultRuntime.writeJson({
@@ -1626,18 +1591,18 @@ export async function runMemoryRemHarness(opts: MemoryRemHarnessOptions) {
skippedPaths,
}
: null,
remConfig,
remConfig: preview.remConfig,
deepConfig: {
minScore: deep.minScore,
minRecallCount: deep.minRecallCount,
minUniqueQueries: deep.minUniqueQueries,
recencyHalfLifeDays: deep.recencyHalfLifeDays,
maxAgeDays: deep.maxAgeDays ?? null,
minScore: preview.deepConfig.minScore,
minRecallCount: preview.deepConfig.minRecallCount,
minUniqueQueries: preview.deepConfig.minUniqueQueries,
recencyHalfLifeDays: preview.deepConfig.recencyHalfLifeDays,
maxAgeDays: preview.deepConfig.maxAgeDays ?? null,
},
rem: remPreview,
rem: { skipped: preview.remSkipped, ...remPreview },
grounded: groundedPreview,
deep: {
candidateCount: deepCandidates.length,
candidateCount: preview.deep.candidateCount,
candidates: deepCandidates,
},
});
@@ -1683,7 +1648,7 @@ export async function runMemoryRemHarness(opts: MemoryRemHarnessOptions) {
colorize(
rich,
theme.muted,
`recentRecallEntries=${recallEntries.length} deepCandidates=${deepCandidates.length}`,
`recentRecallEntries=${preview.recallEntryCount} deepCandidates=${deepCandidates.length}`,
),
"",
colorize(rich, theme.heading, "REM Preview"),

View File

@@ -10,11 +10,17 @@ import {
resolveMemoryRemDreamingConfig,
} from "openclaw/plugin-sdk/memory-core-host-status";
import { describe, expect, it, vi } from "vitest";
import { __testing, runDreamingSweepPhases } from "./dreaming-phases.js";
import {
__testing,
filterRecallEntriesWithinLookback,
runDreamingSweepPhases,
} from "./dreaming-phases.js";
import { previewRemHarness } from "./rem-harness.js";
import {
rankShortTermPromotionCandidates,
recordShortTermRecalls,
resolveShortTermPhaseSignalStorePath,
type ShortTermRecallEntry,
} from "./short-term-promotion.js";
import { createMemoryCoreTestHarness } from "./test-helpers.js";
@@ -2523,3 +2529,179 @@ describe("memory-core dreaming phases", () => {
expect(after2[0]?.dailyCount).toBe(2);
});
});
describe("filterRecallEntriesWithinLookback", () => {
const NOW_MS = new Date("2026-04-15T12:00:00.000Z").getTime();
const LOOKBACK_DAYS = 3;
const STALE_LAST_RECALLED_AT = new Date("2026-03-01T00:00:00.000Z").toISOString();
const FRESH_RECALL_DAY = "2026-04-14";
function makeEntry(
overrides: Partial<ShortTermRecallEntry> & Pick<ShortTermRecallEntry, "key">,
): ShortTermRecallEntry {
return {
key: overrides.key,
path: overrides.path ?? "src/example.ts",
startLine: overrides.startLine ?? 1,
endLine: overrides.endLine ?? 10,
source: "memory",
snippet: overrides.snippet ?? "example snippet",
recallCount: overrides.recallCount ?? 1,
dailyCount: overrides.dailyCount ?? 0,
groundedCount: overrides.groundedCount ?? 0,
totalScore: overrides.totalScore ?? 1,
maxScore: overrides.maxScore ?? 1,
firstRecalledAt: overrides.firstRecalledAt ?? STALE_LAST_RECALLED_AT,
lastRecalledAt: overrides.lastRecalledAt ?? STALE_LAST_RECALLED_AT,
queryHashes: overrides.queryHashes ?? [],
recallDays: overrides.recallDays ?? [],
conceptTags: overrides.conceptTags ?? [],
...(overrides.claimHash !== undefined ? { claimHash: overrides.claimHash } : {}),
...(overrides.promotedAt !== undefined ? { promotedAt: overrides.promotedAt } : {}),
};
}
it("keeps entries with stale lastRecalledAt when recallDays has a recent day", () => {
const entry = makeEntry({
key: "stale-last-recalled-fresh-day",
lastRecalledAt: STALE_LAST_RECALLED_AT,
recallDays: [FRESH_RECALL_DAY],
});
const result = filterRecallEntriesWithinLookback({
entries: [entry],
nowMs: NOW_MS,
lookbackDays: LOOKBACK_DAYS,
});
expect(result).toHaveLength(1);
expect(result[0]?.key).toBe("stale-last-recalled-fresh-day");
});
it("keeps entries with unparseable lastRecalledAt when recallDays has a recent day", () => {
const entry = makeEntry({
key: "bad-last-recalled-fresh-day",
lastRecalledAt: "not-a-date",
recallDays: [FRESH_RECALL_DAY],
});
const result = filterRecallEntriesWithinLookback({
entries: [entry],
nowMs: NOW_MS,
lookbackDays: LOOKBACK_DAYS,
});
expect(result).toHaveLength(1);
expect(result[0]?.key).toBe("bad-last-recalled-fresh-day");
});
it("drops entries whose lastRecalledAt and recallDays are both outside the window", () => {
const entry = makeEntry({
key: "stale-everything",
lastRecalledAt: STALE_LAST_RECALLED_AT,
recallDays: ["2026-03-02"],
});
const result = filterRecallEntriesWithinLookback({
entries: [entry],
nowMs: NOW_MS,
lookbackDays: LOOKBACK_DAYS,
});
expect(result).toHaveLength(0);
});
it("keeps entries with a recent lastRecalledAt even when recallDays is empty", () => {
const entry = makeEntry({
key: "fresh-last-recalled-no-days",
lastRecalledAt: new Date("2026-04-14T00:00:00.000Z").toISOString(),
recallDays: [],
});
const result = filterRecallEntriesWithinLookback({
entries: [entry],
nowMs: NOW_MS,
lookbackDays: LOOKBACK_DAYS,
});
expect(result).toHaveLength(1);
expect(result[0]?.key).toBe("fresh-last-recalled-no-days");
});
});
describe("previewRemHarness", () => {
it("ignores daily-named directories when collecting grounded inputs", async () => {
const workspaceDir = await createDreamingWorkspace();
const memoryDir = path.join(workspaceDir, "memory");
await fs.mkdir(path.join(memoryDir, "2026-04-14.md"), { recursive: true });
await fs.writeFile(path.join(memoryDir, "2026-04-15.md"), "# Day\n\nWorked on REM.\n", "utf-8");
const preview = await previewRemHarness({
workspaceDir,
grounded: true,
pluginConfig: {
dreaming: {
enabled: true,
phases: {
rem: { enabled: true, limit: 10 },
},
},
},
});
expect(preview.groundedInputPaths.map((entry) => path.basename(entry))).toEqual([
"2026-04-15.md",
]);
expect(preview.grounded?.scannedFiles).toBe(1);
});
it("keeps grounded preview null when no grounded inputs exist", async () => {
const workspaceDir = await createDreamingWorkspace();
const preview = await previewRemHarness({
workspaceDir,
grounded: true,
pluginConfig: {
dreaming: {
enabled: true,
phases: {
rem: { enabled: true, limit: 10 },
},
},
},
});
expect(preview.groundedInputPaths).toEqual([]);
expect(preview.grounded).toBeNull();
});
it("skips REM preview when rem.limit=0 while still ranking deep candidates", async () => {
const workspaceDir = await createDreamingWorkspace();
const nowMs = new Date("2026-04-15T12:00:00.000Z").getTime();
await recordShortTermRecalls({
workspaceDir,
query: "outdoor plans",
nowMs,
results: [
{
path: "memory/2026-04-14.md",
startLine: 1,
endLine: 1,
score: 0.92,
snippet: "Always check weather before suggesting outdoor plans.",
source: "memory",
},
],
});
const preview = await previewRemHarness({
workspaceDir,
nowMs,
pluginConfig: {
dreaming: {
enabled: true,
phases: {
rem: { enabled: true, limit: 0 },
},
},
},
});
expect(preview.remSkipped).toBe(true);
expect(preview.rem.candidateTruths).toEqual([]);
expect(preview.rem.bodyLines).toEqual([]);
expect(preview.deep.candidates[0]?.snippet).toContain("Always check weather");
});
});

View File

@@ -362,6 +362,18 @@ function entryWithinLookback(entry: ShortTermRecallEntry, cutoffMs: number): boo
return Number.isFinite(lastRecalledAtMs) && lastRecalledAtMs >= cutoffMs;
}
// Public lookback filter for recall entries. Kept in memory-core so gateway
// doctor harness, CLI harness, and internal REM/light dreaming paths all
// resolve `recallDays` vs `lastRecalledAt` the same way and cannot drift.
export function filterRecallEntriesWithinLookback(params: {
entries: readonly ShortTermRecallEntry[];
nowMs: number;
lookbackDays: number;
}): ShortTermRecallEntry[] {
const cutoffMs = calculateLookbackCutoffMs(params.nowMs, params.lookbackDays);
return params.entries.filter((entry) => entryWithinLookback(entry, cutoffMs));
}
type DailyIngestionBatch = {
day: string;
results: MemorySearchResult[];
@@ -1515,7 +1527,6 @@ async function runLightDreaming(params: {
nowMs?: number;
}): Promise<void> {
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const cutoffMs = calculateLookbackCutoffMs(nowMs, params.config.lookbackDays);
await ingestDailyMemorySignals({
workspaceDir: params.workspaceDir,
lookbackDays: params.config.lookbackDays,
@@ -1532,9 +1543,11 @@ async function runLightDreaming(params: {
});
const recentEntries = await filterLiveShortTermRecallEntries({
workspaceDir: params.workspaceDir,
entries: (
await readShortTermRecallEntries({ workspaceDir: params.workspaceDir, nowMs })
).filter((entry) => entryWithinLookback(entry, cutoffMs)),
entries: filterRecallEntriesWithinLookback({
entries: await readShortTermRecallEntries({ workspaceDir: params.workspaceDir, nowMs }),
nowMs,
lookbackDays: params.config.lookbackDays,
}),
});
const entries = dedupeEntries(
recentEntries
@@ -1611,7 +1624,6 @@ async function runRemDreaming(params: {
nowMs?: number;
}): Promise<void> {
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const cutoffMs = calculateLookbackCutoffMs(nowMs, params.config.lookbackDays);
await ingestDailyMemorySignals({
workspaceDir: params.workspaceDir,
lookbackDays: params.config.lookbackDays,
@@ -1628,9 +1640,11 @@ async function runRemDreaming(params: {
});
const entries = await filterLiveShortTermRecallEntries({
workspaceDir: params.workspaceDir,
entries: (
await readShortTermRecallEntries({ workspaceDir: params.workspaceDir, nowMs })
).filter((entry) => entryWithinLookback(entry, cutoffMs)),
entries: filterRecallEntriesWithinLookback({
entries: await readShortTermRecallEntries({ workspaceDir: params.workspaceDir, nowMs }),
nowMs,
lookbackDays: params.config.lookbackDays,
}),
});
const preview = previewRemDreaming({
entries,

View File

@@ -0,0 +1,201 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import {
resolveMemoryDeepDreamingConfig,
resolveMemoryRemDreamingConfig,
} from "openclaw/plugin-sdk/memory-core-host-status";
import {
filterRecallEntriesWithinLookback,
previewRemDreaming,
type RemDreamingPreview,
} from "./dreaming-phases.js";
import { previewGroundedRemMarkdown, type GroundedRemPreviewResult } from "./rem-evidence.js";
import {
rankShortTermPromotionCandidates,
readShortTermRecallEntries,
type PromotionCandidate,
} from "./short-term-promotion.js";
const DAILY_MEMORY_FILE_NAME_RE = /^\d{4}-\d{2}-\d{2}\.md$/i;
type MemoryRemHarnessRemConfig = ReturnType<typeof resolveMemoryRemDreamingConfig>;
type MemoryRemHarnessDeepConfig = ReturnType<typeof resolveMemoryDeepDreamingConfig>;
export type PreviewRemHarnessOptions = {
workspaceDir: string;
cfg?: OpenClawConfig;
pluginConfig?: Record<string, unknown>;
grounded?: boolean;
groundedInputPaths?: string[];
groundedFileLimit?: number;
includePromoted?: boolean;
candidateLimit?: number;
remPreviewLimit?: number;
nowMs?: number;
};
export type PreviewRemHarnessResult = {
workspaceDir: string;
nowMs: number;
remConfig: MemoryRemHarnessRemConfig;
deepConfig: MemoryRemHarnessDeepConfig;
recallEntryCount: number;
remSkipped: boolean;
rem: RemDreamingPreview;
groundedInputPaths: string[];
grounded: GroundedRemPreviewResult | null;
deep: {
candidateLimit?: number;
candidateCount: number;
truncated: boolean;
candidates: PromotionCandidate[];
};
};
function normalizeOptionalPositiveLimit(value: number | undefined): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value)) {
return undefined;
}
return Math.max(1, Math.floor(value));
}
function resolveRemPreviewLimit(configLimit: number, cap: number | undefined): number {
if (configLimit <= 0) {
return 0;
}
if (typeof cap !== "number" || !Number.isFinite(cap)) {
return configLimit;
}
return Math.max(0, Math.min(configLimit, Math.floor(cap)));
}
function createSkippedRemPreview(): RemDreamingPreview {
return {
sourceEntryCount: 0,
reflections: [],
candidateTruths: [],
candidateKeys: [],
bodyLines: [],
};
}
async function listWorkspaceDailyFiles(workspaceDir: string, limit?: number): Promise<string[]> {
const memoryDir = path.join(workspaceDir, "memory");
let entries: string[] = [];
try {
const dirEntries = await fs.readdir(memoryDir, { withFileTypes: true });
entries = dirEntries
.filter((entry) => entry.isFile() && DAILY_MEMORY_FILE_NAME_RE.test(entry.name))
.map((entry) => entry.name);
} catch (err) {
if ((err as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
return [];
}
throw err;
}
const files = entries
.map((name) => path.join(memoryDir, name))
.toSorted((left, right) => left.localeCompare(right));
if (typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0 || files.length <= limit) {
return files;
}
return files.slice(-Math.floor(limit));
}
function resolveGroundedFileLimit(
configLimit: number,
cap: number | undefined,
): number | undefined {
if (typeof cap !== "number" || !Number.isFinite(cap)) {
return configLimit;
}
const normalizedCap = Math.max(1, Math.floor(cap));
return configLimit > 0 ? Math.min(configLimit, normalizedCap) : normalizedCap;
}
export async function previewRemHarness(
params: PreviewRemHarnessOptions,
): Promise<PreviewRemHarnessResult> {
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const remConfig = resolveMemoryRemDreamingConfig({
pluginConfig: params.pluginConfig,
cfg: params.cfg,
});
const deepConfig = resolveMemoryDeepDreamingConfig({
pluginConfig: params.pluginConfig,
cfg: params.cfg,
});
const allRecallEntries = await readShortTermRecallEntries({
workspaceDir: params.workspaceDir,
nowMs,
});
const recallEntries = filterRecallEntriesWithinLookback({
entries: allRecallEntries,
nowMs,
lookbackDays: remConfig.lookbackDays,
});
const remPreviewLimit = resolveRemPreviewLimit(remConfig.limit, params.remPreviewLimit);
const remSkipped = remConfig.limit <= 0 || remPreviewLimit <= 0;
const rem = remSkipped
? createSkippedRemPreview()
: previewRemDreaming({
entries: recallEntries,
limit: remPreviewLimit,
minPatternStrength: remConfig.minPatternStrength,
});
let groundedInputPaths = params.groundedInputPaths ?? [];
let grounded: GroundedRemPreviewResult | null = null;
if (params.grounded) {
if (groundedInputPaths.length === 0) {
groundedInputPaths = await listWorkspaceDailyFiles(
params.workspaceDir,
resolveGroundedFileLimit(remConfig.limit, params.groundedFileLimit),
);
}
grounded =
groundedInputPaths.length > 0
? await previewGroundedRemMarkdown({
workspaceDir: params.workspaceDir,
inputPaths: groundedInputPaths,
})
: null;
}
const candidateLimit = normalizeOptionalPositiveLimit(params.candidateLimit);
const rankedCandidates = await rankShortTermPromotionCandidates({
workspaceDir: params.workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
includePromoted: Boolean(params.includePromoted),
recencyHalfLifeDays: deepConfig.recencyHalfLifeDays,
maxAgeDays: deepConfig.maxAgeDays,
nowMs,
...(candidateLimit ? { limit: candidateLimit + 1 } : {}),
});
const truncated = typeof candidateLimit === "number" && rankedCandidates.length > candidateLimit;
const candidates =
typeof candidateLimit === "number"
? rankedCandidates.slice(0, candidateLimit)
: rankedCandidates;
return {
workspaceDir: params.workspaceDir,
nowMs,
remConfig,
deepConfig,
recallEntryCount: recallEntries.length,
remSkipped,
rem,
groundedInputPaths,
grounded,
deep: {
...(candidateLimit ? { candidateLimit } : {}),
candidateCount: candidates.length,
truncated,
candidates,
},
};
}

View File

@@ -72,6 +72,7 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
"diagnostics.stability",
"doctor.memory.status",
"doctor.memory.dreamDiary",
"doctor.memory.remHarness",
"logs.tail",
"channels.status",
"status",

View File

@@ -11,6 +11,7 @@ const BASE_METHODS = [
"doctor.memory.resetGroundedShortTerm",
"doctor.memory.repairDreamingArtifacts",
"doctor.memory.dedupeDreamDiary",
"doctor.memory.remHarness",
"logs.tail",
"channels.status",
"channels.start",

View File

@@ -1,8 +1,10 @@
export {
dedupeDreamDiaryEntries,
removeBackfillDiaryEntries,
filterRecallEntriesWithinLookback,
previewGroundedRemMarkdown,
previewRemHarness,
removeBackfillDiaryEntries,
removeGroundedShortTermCandidates,
repairDreamingArtifacts,
writeBackfillDiaryEntries,
removeGroundedShortTermCandidates,
} from "../../plugin-sdk/memory-core-bundled-runtime.js";

View File

@@ -16,6 +16,7 @@ const resolveMemorySearchConfig = vi.hoisted(() =>
);
const getMemorySearchManager = vi.hoisted(() => vi.fn());
const previewGroundedRemMarkdown = vi.hoisted(() => vi.fn());
const previewRemHarness = vi.hoisted(() => vi.fn());
const dedupeDreamDiaryEntries = vi.hoisted(() => vi.fn());
const writeBackfillDiaryEntries = vi.hoisted(() => vi.fn());
const removeBackfillDiaryEntries = vi.hoisted(() => vi.fn());
@@ -42,6 +43,7 @@ vi.mock("../../plugins/memory-runtime.js", () => ({
vi.mock("./doctor.memory-core-runtime.js", () => ({
dedupeDreamDiaryEntries,
previewGroundedRemMarkdown,
previewRemHarness,
writeBackfillDiaryEntries,
removeBackfillDiaryEntries,
removeGroundedShortTermCandidates,
@@ -142,6 +144,20 @@ const invokeDoctorMemoryDedupeDreamDiary = async (respond: ReturnType<typeof vi.
});
};
const invokeDoctorMemoryRemHarness = async (
respond: ReturnType<typeof vi.fn>,
params: Record<string, unknown> = {},
) => {
await doctorHandlers["doctor.memory.remHarness"]({
req: {} as never,
params: params as never,
respond: respond as never,
context: makeRuntimeContext() as never,
client: null,
isWebchatConnect: () => false,
});
};
const expectEmbeddingErrorResponse = (respond: ReturnType<typeof vi.fn>, error: string) => {
expect(respond).toHaveBeenCalledWith(
true,
@@ -1081,3 +1097,299 @@ describe("doctor.memory.dreamDiary", () => {
}
});
});
describe("doctor.memory.remHarness", () => {
const makeHarnessPreview = (
overrides: Partial<{
workspaceDir: string;
remSkipped: boolean;
rem: Record<string, unknown>;
grounded: Record<string, unknown> | null;
deep: Record<string, unknown>;
remConfig: Record<string, unknown>;
deepConfig: Record<string, unknown>;
}> = {},
) => ({
workspaceDir: overrides.workspaceDir ?? "/tmp/openclaw",
nowMs: 0,
remConfig: {
enabled: true,
lookbackDays: 7,
limit: 25,
minPatternStrength: 0.35,
...overrides.remConfig,
},
deepConfig: {
minScore: 0.75,
minRecallCount: 3,
minUniqueQueries: 2,
recencyHalfLifeDays: 14,
...overrides.deepConfig,
},
recallEntryCount: 0,
remSkipped: overrides.remSkipped ?? false,
rem: {
sourceEntryCount: 0,
reflections: [],
candidateTruths: [],
candidateKeys: [],
bodyLines: [],
...overrides.rem,
},
grounded: overrides.grounded ?? null,
groundedInputPaths: [],
deep: {
candidateLimit: 25,
candidateCount: 0,
truncated: false,
candidates: [],
...overrides.deep,
},
});
beforeEach(() => {
getRuntimeConfig.mockClear().mockReturnValue({} as OpenClawConfig);
resolveDefaultAgentId.mockClear().mockReturnValue("main");
resolveAgentWorkspaceDir.mockReset().mockReturnValue("/tmp/openclaw");
previewRemHarness.mockReset().mockResolvedValue(makeHarnessPreview());
previewGroundedRemMarkdown.mockReset();
});
it("returns an empty preview payload for an empty workspace", async () => {
const respond = vi.fn();
await invokeDoctorMemoryRemHarness(respond);
expect(previewRemHarness).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: "/tmp/openclaw",
grounded: false,
includePromoted: false,
candidateLimit: 25,
groundedFileLimit: 10,
remPreviewLimit: 50,
}),
);
expect(previewGroundedRemMarkdown).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
ok: true,
agentId: "main",
workspaceDir: "/tmp/openclaw",
rem: expect.objectContaining({
skipped: false,
sourceEntryCount: 0,
reflections: [],
candidateTruths: [],
}),
grounded: null,
deep: expect.objectContaining({
candidateLimit: 25,
truncated: false,
candidates: [],
}),
}),
undefined,
);
});
it("maps REM preview and deep candidates into the payload", async () => {
previewRemHarness.mockResolvedValue(
makeHarnessPreview({
rem: {
sourceEntryCount: 2,
reflections: ["reflection line"],
candidateTruths: [{ snippet: "truthy snippet", confidence: 0.72, evidence: "a" }],
candidateKeys: ["a"],
bodyLines: ["## REM", "- truthy snippet"],
},
deep: {
candidates: [
{
key: "memory/2026-04-14.md:12:16",
path: "memory/2026-04-14.md",
startLine: 12,
endLine: 16,
source: "memory",
snippet: "durable fact",
recallCount: 4,
uniqueQueries: 3,
avgScore: 0.81,
maxScore: 0.92,
ageDays: 1,
firstRecalledAt: "2026-04-13T10:00:00.000Z",
lastRecalledAt: "2026-04-14T10:00:00.000Z",
promotedAt: undefined,
},
],
},
}),
);
const respond = vi.fn();
await invokeDoctorMemoryRemHarness(respond);
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
ok: true,
rem: expect.objectContaining({
reflections: ["reflection line"],
candidateTruths: [{ snippet: "truthy snippet", confidence: 0.72 }],
bodyLines: ["## REM", "- truthy snippet"],
}),
deep: expect.objectContaining({
candidateLimit: 25,
truncated: false,
candidates: [
expect.objectContaining({
key: "memory/2026-04-14.md:12:16",
path: "memory/2026-04-14.md",
snippet: "durable fact",
recallCount: 4,
uniqueQueries: 3,
avgScore: 0.81,
promoted: false,
}),
],
}),
}),
undefined,
);
});
it("invokes grounded preview when grounded=true and daily files exist", async () => {
previewRemHarness.mockResolvedValue(
makeHarnessPreview({
grounded: {
scannedFiles: 2,
files: [
{ path: "memory/2026-04-13.md", renderedMarkdown: "## REM\n- a" },
{ path: "memory/2026-04-14.md", renderedMarkdown: "## REM\n- b" },
],
},
}),
);
const respond = vi.fn();
await invokeDoctorMemoryRemHarness(respond, { grounded: true });
expect(previewRemHarness).toHaveBeenCalledWith(expect.objectContaining({ grounded: true }));
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
grounded: expect.objectContaining({
scannedFiles: 2,
files: [
{ path: "memory/2026-04-13.md", renderedMarkdown: "## REM\n- a" },
{ path: "memory/2026-04-14.md", renderedMarkdown: "## REM\n- b" },
],
}),
}),
undefined,
);
});
it("passes bounded grounded and REM preview limits to the shared harness", async () => {
const respond = vi.fn();
await invokeDoctorMemoryRemHarness(respond, { grounded: true });
expect(previewRemHarness).toHaveBeenCalledWith(
expect.objectContaining({
grounded: true,
groundedFileLimit: 10,
remPreviewLimit: 50,
}),
);
});
it("maps requested empty grounded preview into an empty payload", async () => {
const respond = vi.fn();
await invokeDoctorMemoryRemHarness(respond, { grounded: true });
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
grounded: { scannedFiles: 0, files: [] },
}),
undefined,
);
});
it("returns an error payload when the recall store read fails", async () => {
previewRemHarness.mockRejectedValue(new Error("disk boom"));
const respond = vi.fn();
await invokeDoctorMemoryRemHarness(respond);
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
ok: false,
agentId: "main",
workspaceDir: "/tmp/openclaw",
error: expect.stringContaining("disk boom"),
}),
undefined,
);
});
it("caps deep candidates and reports truncated when the store exceeds the limit", async () => {
const overflowCandidate = (index: number) => ({
key: `memory/2026-04-14.md:${index}:${index + 1}`,
path: "memory/2026-04-14.md",
startLine: index,
endLine: index + 1,
source: "memory",
snippet: `snippet-${index}`,
recallCount: 3,
uniqueQueries: 2,
avgScore: 0.6,
maxScore: 0.9,
ageDays: 1,
firstRecalledAt: "2026-04-13T10:00:00.000Z",
lastRecalledAt: "2026-04-14T10:00:00.000Z",
promotedAt: undefined,
});
previewRemHarness.mockResolvedValue(
makeHarnessPreview({
deep: {
candidateLimit: 25,
candidateCount: 25,
truncated: true,
candidates: Array.from({ length: 25 }, (_unused, index) => overflowCandidate(index)),
},
}),
);
const respond = vi.fn();
await invokeDoctorMemoryRemHarness(respond);
expect(previewRemHarness).toHaveBeenCalledWith(expect.objectContaining({ candidateLimit: 25 }));
const payload = respond.mock.calls[0]?.[1] as {
ok: boolean;
deep: { candidateLimit: number; truncated: boolean; candidates: unknown[] };
};
expect(payload.ok).toBe(true);
expect(payload.deep.candidateLimit).toBe(25);
expect(payload.deep.truncated).toBe(true);
expect(payload.deep.candidates).toHaveLength(25);
});
it("clamps caller-supplied limit within [1, REM_HARNESS_MAX_CANDIDATE_LIMIT]", async () => {
const respond = vi.fn();
await invokeDoctorMemoryRemHarness(respond, { limit: 500 });
expect(previewRemHarness).toHaveBeenCalledWith(
expect.objectContaining({ candidateLimit: 100 }),
);
const payload = respond.mock.calls[0]?.[1] as {
deep: { candidateLimit: number };
};
expect(payload.deep.candidateLimit).toBe(100);
});
});

View File

@@ -15,9 +15,10 @@ import { getActiveMemorySearchManager } from "../../plugins/memory-runtime.js";
import { formatError } from "../server-utils.js";
import {
dedupeDreamDiaryEntries,
previewGroundedRemMarkdown,
previewRemHarness,
removeBackfillDiaryEntries,
removeGroundedShortTermCandidates,
previewGroundedRemMarkdown,
repairDreamingArtifacts,
writeBackfillDiaryEntries,
} from "./doctor.memory-core-runtime.js";
@@ -30,6 +31,10 @@ const MANAGED_DEEP_SLEEP_CRON_NAME = "Memory Dreaming Promotion";
const MANAGED_DEEP_SLEEP_CRON_TAG = "[managed-by=memory-core.short-term-promotion]";
const DEEP_SLEEP_SYSTEM_EVENT_TEXT = "__openclaw_memory_core_short_term_promotion_dream__";
const DREAM_DIARY_FILE_NAMES = ["DREAMS.md", "dreams.md"] as const;
const REM_HARNESS_DEFAULT_CANDIDATE_LIMIT = 25;
const REM_HARNESS_MAX_CANDIDATE_LIMIT = 100;
const REM_HARNESS_MAX_GROUNDED_FILES = 10;
const REM_HARNESS_MAX_REM_PREVIEW_LIMIT = 50;
type DoctorMemoryDreamingPhasePayload = {
enabled: boolean;
@@ -153,6 +158,79 @@ export type DoctorMemoryDreamActionPayload = {
keptEntries?: number;
};
export type DoctorMemoryRemHarnessCandidatePayload = {
key: string;
path: string;
startLine: number;
endLine: number;
snippet: string;
recallCount: number;
uniqueQueries: number;
avgScore: number;
maxScore: number;
ageDays: number;
firstRecalledAt: string;
lastRecalledAt: string;
promoted: boolean;
promotedAt?: string;
};
export type DoctorMemoryRemHarnessCandidateTruthPayload = {
snippet: string;
confidence: number;
};
export type DoctorMemoryRemHarnessGroundedFilePayload = {
path: string;
renderedMarkdown: string;
};
export type DoctorMemoryRemHarnessSuccessPayload = {
ok: true;
agentId: string;
workspaceDir: string;
remConfig: {
enabled: boolean;
lookbackDays: number;
limit: number;
minPatternStrength: number;
};
deepConfig: {
minScore: number;
minRecallCount: number;
minUniqueQueries: number;
recencyHalfLifeDays: number;
maxAgeDays: number | null;
};
rem: {
skipped: boolean;
sourceEntryCount: number;
reflections: string[];
candidateTruths: DoctorMemoryRemHarnessCandidateTruthPayload[];
bodyLines: string[];
};
grounded: {
scannedFiles: number;
files: DoctorMemoryRemHarnessGroundedFilePayload[];
} | null;
deep: {
candidateLimit: number;
truncated: boolean;
candidates: DoctorMemoryRemHarnessCandidatePayload[];
};
};
export type DoctorMemoryRemHarnessErrorPayload = {
ok: false;
agentId: string;
workspaceDir: string;
error: string;
};
export type DoctorMemoryRemHarnessPayload =
| DoctorMemoryRemHarnessSuccessPayload
| DoctorMemoryRemHarnessErrorPayload;
function extractIsoDayFromPath(filePath: string): string | null {
const match = filePath.replaceAll("\\", "/").match(/(\d{4}-\d{2}-\d{2})\.md$/i);
return match?.[1] ?? null;
@@ -1025,4 +1103,109 @@ export const doctorHandlers: GatewayRequestHandlers = {
};
respond(true, payload, undefined);
},
"doctor.memory.remHarness": async ({ params, respond, context }) => {
const cfg = context.getRuntimeConfig();
const agentId = resolveDefaultAgentId(cfg);
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const req = asRecord(params);
const grounded = Boolean(req?.grounded);
const includePromoted = Boolean(req?.includePromoted);
const requestedLimit =
typeof req?.limit === "number" && Number.isFinite(req.limit)
? Math.floor(req.limit)
: REM_HARNESS_DEFAULT_CANDIDATE_LIMIT;
const candidateLimit = Math.max(1, Math.min(REM_HARNESS_MAX_CANDIDATE_LIMIT, requestedLimit));
try {
const preview = await previewRemHarness({
workspaceDir,
cfg,
pluginConfig: resolveMemoryDreamingPluginConfig(cfg),
grounded,
includePromoted,
candidateLimit,
groundedFileLimit: REM_HARNESS_MAX_GROUNDED_FILES,
remPreviewLimit: REM_HARNESS_MAX_REM_PREVIEW_LIMIT,
});
const groundedPayload: DoctorMemoryRemHarnessSuccessPayload["grounded"] = preview.grounded
? {
scannedFiles: preview.grounded.scannedFiles,
files: preview.grounded.files.map((file) => ({
path: file.path,
renderedMarkdown: file.renderedMarkdown,
})),
}
: grounded
? { scannedFiles: 0, files: [] }
: null;
const payload: DoctorMemoryRemHarnessSuccessPayload = {
ok: true,
agentId,
workspaceDir,
remConfig: {
enabled: preview.remConfig.enabled,
lookbackDays: preview.remConfig.lookbackDays,
limit: preview.remConfig.limit,
minPatternStrength: preview.remConfig.minPatternStrength,
},
deepConfig: {
minScore: preview.deepConfig.minScore,
minRecallCount: preview.deepConfig.minRecallCount,
minUniqueQueries: preview.deepConfig.minUniqueQueries,
recencyHalfLifeDays: preview.deepConfig.recencyHalfLifeDays,
maxAgeDays:
typeof preview.deepConfig.maxAgeDays === "number"
? preview.deepConfig.maxAgeDays
: null,
},
rem: {
skipped: preview.remSkipped,
sourceEntryCount: preview.rem.sourceEntryCount,
reflections: [...preview.rem.reflections],
candidateTruths: preview.rem.candidateTruths.map((truth) => ({
snippet: truth.snippet,
confidence: truth.confidence,
})),
bodyLines: [...preview.rem.bodyLines],
},
grounded: groundedPayload,
deep: {
candidateLimit,
truncated: preview.deep.truncated,
candidates: preview.deep.candidates.map((candidate) => {
const promoted =
typeof candidate.promotedAt === "string" && candidate.promotedAt.length > 0;
const payload: DoctorMemoryRemHarnessCandidatePayload = {
key: candidate.key,
path: candidate.path,
startLine: candidate.startLine,
endLine: candidate.endLine,
snippet: candidate.snippet,
recallCount: candidate.recallCount,
uniqueQueries: candidate.uniqueQueries,
avgScore: candidate.avgScore,
maxScore: candidate.maxScore,
ageDays: candidate.ageDays,
firstRecalledAt: candidate.firstRecalledAt,
lastRecalledAt: candidate.lastRecalledAt,
promoted,
};
if (promoted) {
payload.promotedAt = candidate.promotedAt;
}
return payload;
}),
},
};
respond(true, payload, undefined);
} catch (err) {
const payload: DoctorMemoryRemHarnessErrorPayload = {
ok: false,
agentId,
workspaceDir,
error: `gateway rem-harness probe failed: ${formatError(err)}`,
};
respond(true, payload, undefined);
}
},
};

View File

@@ -7,6 +7,8 @@ const removeGroundedShortTermCandidatesImpl = vi.hoisted(() => vi.fn());
const previewGroundedRemMarkdownImpl = vi.hoisted(() => vi.fn());
const writeBackfillDiaryEntriesImpl = vi.hoisted(() => vi.fn());
const removeBackfillDiaryEntriesImpl = vi.hoisted(() => vi.fn());
const filterRecallEntriesWithinLookbackImpl = vi.hoisted(() => vi.fn());
const previewRemHarnessImpl = vi.hoisted(() => vi.fn());
vi.mock("./facade-loader.js", async () => {
const actual = await vi.importActual<typeof import("./facade-loader.js")>("./facade-loader.js");
@@ -24,6 +26,8 @@ describe("plugin-sdk memory-core bundled runtime", () => {
previewGroundedRemMarkdownImpl.mockReset().mockResolvedValue({ files: [] });
writeBackfillDiaryEntriesImpl.mockReset().mockResolvedValue({ writtenCount: 1 });
removeBackfillDiaryEntriesImpl.mockReset().mockResolvedValue({ removedCount: 1 });
filterRecallEntriesWithinLookbackImpl.mockReset().mockReturnValue([]);
previewRemHarnessImpl.mockReset().mockResolvedValue({ ok: true });
loadBundledPluginPublicSurfaceModuleSync
.mockReset()
.mockImplementation(({ artifactBasename }) => {
@@ -39,6 +43,8 @@ describe("plugin-sdk memory-core bundled runtime", () => {
previewGroundedRemMarkdown: previewGroundedRemMarkdownImpl,
writeBackfillDiaryEntries: writeBackfillDiaryEntriesImpl,
removeBackfillDiaryEntries: removeBackfillDiaryEntriesImpl,
filterRecallEntriesWithinLookback: filterRecallEntriesWithinLookbackImpl,
previewRemHarness: previewRemHarnessImpl,
};
}
throw new Error(`unexpected artifact ${String(artifactBasename)}`);
@@ -75,4 +81,36 @@ describe("plugin-sdk memory-core bundled runtime", () => {
artifactBasename: "runtime-api.js",
});
});
it("delegates filterRecallEntriesWithinLookback through the bundled api surface", async () => {
const module = await import("./memory-core-bundled-runtime.js");
const kept = [{ key: "keep" }] as never;
filterRecallEntriesWithinLookbackImpl.mockReturnValueOnce(kept);
const params = { entries: [] as never, nowMs: 0, lookbackDays: 1 };
const result = module.filterRecallEntriesWithinLookback(params);
expect(result).toBe(kept);
expect(filterRecallEntriesWithinLookbackImpl).toHaveBeenCalledWith(params);
expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({
dirName: "memory-core",
artifactBasename: "api.js",
});
});
it("delegates previewRemHarness through the bundled api surface", async () => {
const module = await import("./memory-core-bundled-runtime.js");
const preview = { workspaceDir: "/tmp/openclaw" };
previewRemHarnessImpl.mockResolvedValueOnce(preview);
const params = { workspaceDir: "/tmp/openclaw", candidateLimit: 3 };
const result = await module.previewRemHarness(params);
expect(result).toBe(preview);
expect(previewRemHarnessImpl).toHaveBeenCalledWith(params);
expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({
dirName: "memory-core",
artifactBasename: "api.js",
});
});
});

View File

@@ -60,6 +60,63 @@ type GroundedRemPreviewResult = {
files: GroundedRemFilePreview[];
};
type RemDreamingPreview = {
sourceEntryCount: number;
reflections: string[];
candidateTruths: Array<{
snippet: string;
confidence: number;
evidence: string;
}>;
candidateKeys: string[];
bodyLines: string[];
};
type PromotionCandidate = {
key: string;
path: string;
startLine: number;
endLine: number;
snippet: string;
recallCount: number;
uniqueQueries: number;
avgScore: number;
maxScore: number;
ageDays: number;
firstRecalledAt: string;
lastRecalledAt: string;
promotedAt?: string;
};
type RemHarnessPreviewResult = {
workspaceDir: string;
nowMs: number;
remConfig: {
enabled: boolean;
lookbackDays: number;
limit: number;
minPatternStrength: number;
};
deepConfig: {
minScore: number;
minRecallCount: number;
minUniqueQueries: number;
recencyHalfLifeDays: number;
maxAgeDays?: number;
};
recallEntryCount: number;
remSkipped: boolean;
rem: RemDreamingPreview;
groundedInputPaths: string[];
grounded: GroundedRemPreviewResult | null;
deep: {
candidateLimit?: number;
candidateCount: number;
truncated: boolean;
candidates: PromotionCandidate[];
};
};
type ApiFacadeModule = {
previewGroundedRemMarkdown: (params: {
workspaceDir: string;
@@ -80,6 +137,23 @@ type ApiFacadeModule = {
removeBackfillDiaryEntries: (params: {
workspaceDir: string;
}) => Promise<{ dreamsPath: string; removed: number }>;
filterRecallEntriesWithinLookback: (params: {
entries: readonly unknown[];
nowMs: number;
lookbackDays: number;
}) => unknown[];
previewRemHarness: (params: {
workspaceDir: string;
cfg?: unknown;
pluginConfig?: Record<string, unknown>;
grounded?: boolean;
groundedInputPaths?: string[];
groundedFileLimit?: number;
includePromoted?: boolean;
candidateLimit?: number;
remPreviewLimit?: number;
nowMs?: number;
}) => Promise<RemHarnessPreviewResult>;
};
type RepairDreamingArtifactsResult = {
@@ -150,3 +224,12 @@ export const removeBackfillDiaryEntries: ApiFacadeModule["removeBackfillDiaryEnt
loadApiFacadeModule().removeBackfillDiaryEntries(
...args,
)) as ApiFacadeModule["removeBackfillDiaryEntries"];
export const filterRecallEntriesWithinLookback: ApiFacadeModule["filterRecallEntriesWithinLookback"] =
((...args) =>
loadApiFacadeModule().filterRecallEntriesWithinLookback(
...args,
)) as ApiFacadeModule["filterRecallEntriesWithinLookback"];
export const previewRemHarness: ApiFacadeModule["previewRemHarness"] = ((...args) =>
loadApiFacadeModule().previewRemHarness(...args)) as ApiFacadeModule["previewRemHarness"];