diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b406b5371d..fae4f6c2705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index aab45284835..ef12e71d8bb 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -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. diff --git a/extensions/memory-core/api.ts b/extensions/memory-core/api.ts index 6e3c6bb7327..d36a9ada62a 100644 --- a/extensions/memory-core/api.ts +++ b/extensions/memory-core/api.ts @@ -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"; diff --git a/extensions/memory-core/src/cli.runtime.ts b/extensions/memory-core/src/cli.runtime.ts index ae05a7ddbe6..522bb504dc5 100644 --- a/extensions/memory-core/src/cli.runtime.ts +++ b/extensions/memory-core/src/cli.runtime.ts @@ -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 { - 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"), diff --git a/extensions/memory-core/src/dreaming-phases.test.ts b/extensions/memory-core/src/dreaming-phases.test.ts index c7d9d382d24..d37ea2190e5 100644 --- a/extensions/memory-core/src/dreaming-phases.test.ts +++ b/extensions/memory-core/src/dreaming-phases.test.ts @@ -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 & Pick, + ): 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"); + }); +}); diff --git a/extensions/memory-core/src/dreaming-phases.ts b/extensions/memory-core/src/dreaming-phases.ts index 948904efa72..f5dd915aa75 100644 --- a/extensions/memory-core/src/dreaming-phases.ts +++ b/extensions/memory-core/src/dreaming-phases.ts @@ -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 { 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 { 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, diff --git a/extensions/memory-core/src/rem-harness.ts b/extensions/memory-core/src/rem-harness.ts new file mode 100644 index 00000000000..8fc5fbd3575 --- /dev/null +++ b/extensions/memory-core/src/rem-harness.ts @@ -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; +type MemoryRemHarnessDeepConfig = ReturnType; + +export type PreviewRemHarnessOptions = { + workspaceDir: string; + cfg?: OpenClawConfig; + pluginConfig?: Record; + 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 { + 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 { + 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, + }, + }; +} diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index a55f04aae2a..a2014be1f4a 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -72,6 +72,7 @@ const METHOD_SCOPE_GROUPS: Record = { "diagnostics.stability", "doctor.memory.status", "doctor.memory.dreamDiary", + "doctor.memory.remHarness", "logs.tail", "channels.status", "status", diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 6723a2a6f65..0968ab58a7e 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -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", diff --git a/src/gateway/server-methods/doctor.memory-core-runtime.ts b/src/gateway/server-methods/doctor.memory-core-runtime.ts index 5266d92a42e..1912107d72c 100644 --- a/src/gateway/server-methods/doctor.memory-core-runtime.ts +++ b/src/gateway/server-methods/doctor.memory-core-runtime.ts @@ -1,8 +1,10 @@ export { dedupeDreamDiaryEntries, - removeBackfillDiaryEntries, + filterRecallEntriesWithinLookback, previewGroundedRemMarkdown, + previewRemHarness, + removeBackfillDiaryEntries, + removeGroundedShortTermCandidates, repairDreamingArtifacts, writeBackfillDiaryEntries, - removeGroundedShortTermCandidates, } from "../../plugin-sdk/memory-core-bundled-runtime.js"; diff --git a/src/gateway/server-methods/doctor.test.ts b/src/gateway/server-methods/doctor.test.ts index 85555df3ee6..c1587ed49f6 100644 --- a/src/gateway/server-methods/doctor.test.ts +++ b/src/gateway/server-methods/doctor.test.ts @@ -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, + params: Record = {}, +) => { + 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, 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; + grounded: Record | null; + deep: Record; + remConfig: Record; + deepConfig: Record; + }> = {}, + ) => ({ + 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); + }); +}); diff --git a/src/gateway/server-methods/doctor.ts b/src/gateway/server-methods/doctor.ts index b8571cd7c0e..382f19ce387 100644 --- a/src/gateway/server-methods/doctor.ts +++ b/src/gateway/server-methods/doctor.ts @@ -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); + } + }, }; diff --git a/src/plugin-sdk/memory-core-bundled-runtime.test.ts b/src/plugin-sdk/memory-core-bundled-runtime.test.ts index 995f7adac59..43a3c0a2365 100644 --- a/src/plugin-sdk/memory-core-bundled-runtime.test.ts +++ b/src/plugin-sdk/memory-core-bundled-runtime.test.ts @@ -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("./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", + }); + }); }); diff --git a/src/plugin-sdk/memory-core-bundled-runtime.ts b/src/plugin-sdk/memory-core-bundled-runtime.ts index 9d7324871f2..17688184bb8 100644 --- a/src/plugin-sdk/memory-core-bundled-runtime.ts +++ b/src/plugin-sdk/memory-core-bundled-runtime.ts @@ -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; + grounded?: boolean; + groundedInputPaths?: string[]; + groundedFileLimit?: number; + includePromoted?: boolean; + candidateLimit?: number; + remPreviewLimit?: number; + nowMs?: number; + }) => Promise; }; 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"];