mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:40:42 +00:00
feat(gateway): add doctor.memory.remHarness probe
Signed-off-by: samzong <samzong.lu@gmail.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
201
extensions/memory-core/src/rem-harness.ts
Normal file
201
extensions/memory-core/src/rem-harness.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
export {
|
||||
dedupeDreamDiaryEntries,
|
||||
removeBackfillDiaryEntries,
|
||||
filterRecallEntriesWithinLookback,
|
||||
previewGroundedRemMarkdown,
|
||||
previewRemHarness,
|
||||
removeBackfillDiaryEntries,
|
||||
removeGroundedShortTermCandidates,
|
||||
repairDreamingArtifacts,
|
||||
writeBackfillDiaryEntries,
|
||||
removeGroundedShortTermCandidates,
|
||||
} from "../../plugin-sdk/memory-core-bundled-runtime.js";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"];
|
||||
|
||||
Reference in New Issue
Block a user