feat(memory): add grounded REM backfill lane (#63273)

Merged via squash.

Prepared head SHA: 4450f25485
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-04-08 20:23:28 +02:00
committed by GitHub
parent 9e4f478f86
commit dbf5960bd9
10 changed files with 1842 additions and 94 deletions

View File

@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
- iOS: pin release versioning to an explicit CalVer in `apps/ios/version.json`, keep TestFlight iteration on the same short version until maintainers intentionally promote the next gateway version, and add the documented `pnpm ios:version:pin -- --from-gateway` workflow for release trains. (#63001) Thanks @ngutman.
- Plugins/provider-auth: let provider manifests declare `providerAuthAliases` so provider variants can share env vars, auth profiles, config-backed auth, and API-key onboarding choices without core-specific wiring.
- Memory/dreaming: add a grounded REM backfill lane with historical `rem-harness --path`, diary commit, and reset flows so old daily notes can be replayed safely into `DREAMS.md`. Thanks @mbelinky.
### Fixes

View File

@@ -4,3 +4,8 @@ export type {
MemoryProviderStatus,
MemorySyncProgressUpdate,
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
export {
removeBackfillDiaryEntries,
writeBackfillDiaryEntries,
} from "./src/dreaming-narrative.js";
export { previewGroundedRemMarkdown } from "./src/rem-evidence.js";

View File

@@ -4,7 +4,6 @@ import os from "node:os";
import path from "node:path";
import { resolveMemoryRemDreamingConfig } from "openclaw/plugin-sdk/memory-core-host-status";
import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import {
colorize,
defaultRuntime,
@@ -31,10 +30,13 @@ import type {
MemoryCommandOptions,
MemoryPromoteCommandOptions,
MemoryPromoteExplainOptions,
MemoryRemBackfillOptions,
MemoryRemHarnessOptions,
MemorySearchCommandOptions,
} from "./cli.types.js";
import { previewRemDreaming } from "./dreaming-phases.js";
import { previewRemDreaming, seedHistoricalDailyMemorySignals } from "./dreaming-phases.js";
import { removeBackfillDiaryEntries, writeBackfillDiaryEntries } from "./dreaming-narrative.js";
import { previewGroundedRemMarkdown } from "./rem-evidence.js";
import { asRecord } from "./dreaming-shared.js";
import { resolveShortTermPromotionDreamingConfig } from "./dreaming.js";
import {
@@ -114,6 +116,78 @@ function resolveMemoryPluginConfig(cfg: OpenClawConfig): Record<string, unknown>
return asRecord(entry?.config) ?? {};
}
const DAILY_MEMORY_FILE_NAME_RE = /^(\d{4}-\d{2}-\d{2})\.md$/;
async function listHistoricalDailyFiles(inputPath: string): Promise<string[]> {
const resolvedPath = path.resolve(inputPath);
const stat = await fs.stat(resolvedPath);
if (stat.isFile()) {
return DAILY_MEMORY_FILE_NAME_RE.test(path.basename(resolvedPath)) ? [resolvedPath] : [];
}
if (!stat.isDirectory()) {
return [];
}
const entries = await fs.readdir(resolvedPath, { withFileTypes: true });
return entries
.filter((entry) => entry.isFile() && DAILY_MEMORY_FILE_NAME_RE.test(entry.name))
.map((entry) => path.join(resolvedPath, entry.name))
.toSorted((a, b) => path.basename(a).localeCompare(path.basename(b)));
}
async function createHistoricalRemHarnessWorkspace(params: {
inputPath: string;
remLimit: number;
nowMs: number;
timezone?: string;
}): Promise<{
workspaceDir: string;
sourceFiles: string[];
workspaceSourceFiles: string[];
importedFileCount: number;
importedSignalCount: number;
skippedPaths: string[];
}> {
const sourceFiles = await listHistoricalDailyFiles(params.inputPath);
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-rem-harness-"));
const memoryDir = path.join(workspaceDir, "memory");
await fs.mkdir(memoryDir, { recursive: true });
for (const filePath of sourceFiles) {
await fs.copyFile(filePath, path.join(memoryDir, path.basename(filePath)));
}
const workspaceSourceFiles = sourceFiles.map((entry) => path.join(memoryDir, path.basename(entry)));
const seeded = await seedHistoricalDailyMemorySignals({
workspaceDir,
filePaths: workspaceSourceFiles,
limit: params.remLimit,
nowMs: params.nowMs,
timezone: params.timezone,
});
return {
workspaceDir,
sourceFiles,
workspaceSourceFiles,
importedFileCount: seeded.importedFileCount,
importedSignalCount: seeded.importedSignalCount,
skippedPaths: seeded.skippedPaths,
};
}
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 });
@@ -208,6 +282,18 @@ function formatExtraPaths(workspaceDir: string, extraPaths: string[]): string[]
return normalizeExtraMemoryPaths(workspaceDir, extraPaths).map((entry) => shortenHomePath(entry));
}
function extractIsoDayFromPath(filePath: string): string | null {
const match = path.basename(filePath).match(DAILY_MEMORY_FILE_NAME_RE);
return match?.[1] ?? null;
}
function groundedMarkdownToDiaryLines(markdown: string): string[] {
return markdown
.split(/\r?\n/)
.map((line) => line.replace(/^##\s+/, "").trimEnd())
.filter((line, index, lines) => !(line.length === 0 && lines[index - 1]?.length === 0));
}
function matchesPromotionSelector(
candidate: {
key: string;
@@ -216,15 +302,15 @@ function matchesPromotionSelector(
},
selector: string,
): boolean {
const trimmed = normalizeLowercaseStringOrEmpty(selector);
const trimmed = selector.trim().toLowerCase();
if (!trimmed) {
return false;
}
return (
normalizeLowercaseStringOrEmpty(candidate.key) === trimmed ||
normalizeLowercaseStringOrEmpty(candidate.key).includes(trimmed) ||
normalizeLowercaseStringOrEmpty(candidate.path).includes(trimmed) ||
normalizeLowercaseStringOrEmpty(candidate.snippet).includes(trimmed)
candidate.key.toLowerCase() === trimmed ||
candidate.key.toLowerCase().includes(trimmed) ||
candidate.path.toLowerCase().includes(trimmed) ||
candidate.snippet.toLowerCase().includes(trimmed)
);
}
@@ -1250,13 +1336,13 @@ export async function runMemoryRemHarness(opts: MemoryRemHarnessOptions) {
purpose: "status",
run: async (manager) => {
const status = manager.status();
const workspaceDir = status.workspaceDir?.trim();
const managerWorkspaceDir = status.workspaceDir?.trim();
const pluginConfig = resolveMemoryPluginConfig(cfg);
const deep = resolveShortTermPromotionDreamingConfig({
pluginConfig,
cfg,
});
if (!workspaceDir) {
if (!managerWorkspaceDir && !opts.path) {
defaultRuntime.error("Memory rem-harness requires a resolvable workspace directory.");
process.exitCode = 1;
return;
@@ -1266,69 +1352,297 @@ export async function runMemoryRemHarness(opts: MemoryRemHarnessOptions) {
cfg,
});
const nowMs = Date.now();
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 deepCandidates = await rankShortTermPromotionCandidates({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
includePromoted: Boolean(opts.includePromoted),
recencyHalfLifeDays: deep.recencyHalfLifeDays,
maxAgeDays: deep.maxAgeDays,
});
if (opts.json) {
defaultRuntime.writeJson({
workspaceDir,
remConfig,
deepConfig: {
minScore: deep.minScore,
minRecallCount: deep.minRecallCount,
minUniqueQueries: deep.minUniqueQueries,
recencyHalfLifeDays: deep.recencyHalfLifeDays,
maxAgeDays: deep.maxAgeDays ?? null,
},
rem: remPreview,
deep: {
candidateCount: deepCandidates.length,
candidates: deepCandidates,
},
let workspaceDir = managerWorkspaceDir ?? "";
let sourceFiles: string[] = [];
let groundedInputPaths: string[] = [];
let importedFileCount = 0;
let importedSignalCount = 0;
let skippedPaths: string[] = [];
let cleanupWorkspaceDir: string | null = null;
if (opts.path) {
const historical = await createHistoricalRemHarnessWorkspace({
inputPath: opts.path,
remLimit: remConfig.limit,
nowMs,
timezone: remConfig.timezone,
});
workspaceDir = historical.workspaceDir;
cleanupWorkspaceDir = historical.workspaceDir;
sourceFiles = historical.sourceFiles;
groundedInputPaths = historical.workspaceSourceFiles;
importedFileCount = historical.importedFileCount;
importedSignalCount = historical.importedSignalCount;
skippedPaths = historical.skippedPaths;
if (sourceFiles.length === 0) {
await fs.rm(historical.workspaceDir, { recursive: true, force: true });
defaultRuntime.error(
`Memory rem-harness found no YYYY-MM-DD.md files at ${shortenHomePath(path.resolve(opts.path))}.`,
);
process.exitCode = 1;
return;
}
}
if (!workspaceDir) {
defaultRuntime.error("Memory rem-harness requires a resolvable workspace directory.");
process.exitCode = 1;
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({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
includePromoted: Boolean(opts.includePromoted),
recencyHalfLifeDays: deep.recencyHalfLifeDays,
maxAgeDays: deep.maxAgeDays,
});
const rich = isRich();
const lines = [
`${colorize(rich, theme.heading, "REM Harness")} ${colorize(rich, theme.muted, `(${agentId})`)}`,
colorize(rich, theme.muted, `workspace=${shortenHomePath(workspaceDir)}`),
colorize(
rich,
theme.muted,
`recentRecallEntries=${recallEntries.length} deepCandidates=${deepCandidates.length}`,
),
"",
colorize(rich, theme.heading, "REM Preview"),
...remPreview.bodyLines,
"",
colorize(rich, theme.heading, "Deep Candidates"),
...(deepCandidates.length > 0
? deepCandidates
.slice(0, 10)
.map(
(candidate) =>
`${candidate.score.toFixed(3)} ${candidate.snippet} [${shortenHomePath(candidate.path)}:${candidate.startLine}-${candidate.endLine}]`,
)
: ["- No deep candidates."]),
];
defaultRuntime.log(lines.join("\n"));
if (opts.json) {
defaultRuntime.writeJson({
workspaceDir,
sourcePath: opts.path ? path.resolve(opts.path) : null,
sourceFiles,
historicalImport:
opts.path
? {
importedFileCount,
importedSignalCount,
skippedPaths,
}
: null,
remConfig,
deepConfig: {
minScore: deep.minScore,
minRecallCount: deep.minRecallCount,
minUniqueQueries: deep.minUniqueQueries,
recencyHalfLifeDays: deep.recencyHalfLifeDays,
maxAgeDays: deep.maxAgeDays ?? null,
},
rem: remPreview,
grounded: groundedPreview,
deep: {
candidateCount: deepCandidates.length,
candidates: deepCandidates,
},
});
return;
}
const rich = isRich();
const lines = [
`${colorize(rich, theme.heading, "REM Harness")} ${colorize(rich, theme.muted, `(${agentId})`)}`,
colorize(rich, theme.muted, `workspace=${shortenHomePath(workspaceDir)}`),
...(opts.path
? [
colorize(
rich,
theme.muted,
`sourcePath=${shortenHomePath(path.resolve(opts.path))}`,
),
colorize(
rich,
theme.muted,
`historicalFiles=${sourceFiles.length} importedFiles=${importedFileCount} importedSignals=${importedSignalCount}`,
),
...(skippedPaths.length > 0
? [
colorize(
rich,
theme.warn,
`skipped=${skippedPaths.map((entry) => shortenHomePath(entry)).join(", ")}`,
),
]
: []),
]
: []),
...(opts.grounded
? [
colorize(
rich,
theme.muted,
`groundedInputs=${groundedInputPaths.length > 0 ? groundedInputPaths.map((entry) => shortenHomePath(entry)).join(", ") : "none"}`,
),
]
: []),
colorize(
rich,
theme.muted,
`recentRecallEntries=${recallEntries.length} deepCandidates=${deepCandidates.length}`,
),
"",
colorize(rich, theme.heading, "REM Preview"),
...remPreview.bodyLines,
...(groundedPreview
? [
"",
colorize(rich, theme.heading, "Grounded REM"),
...groundedPreview.files.flatMap((file) => [
colorize(rich, theme.label, file.path),
file.renderedMarkdown,
"",
]),
]
: []),
"",
colorize(rich, theme.heading, "Deep Candidates"),
...(deepCandidates.length > 0
? deepCandidates
.slice(0, 10)
.map(
(candidate) =>
`${candidate.score.toFixed(3)} ${candidate.snippet} [${shortenHomePath(candidate.path)}:${candidate.startLine}-${candidate.endLine}]`,
)
: ["- No deep candidates."]),
];
defaultRuntime.log(lines.join("\n"));
} finally {
if (cleanupWorkspaceDir) {
await fs.rm(cleanupWorkspaceDir, { recursive: true, force: true });
}
}
},
});
}
export async function runMemoryRemBackfill(opts: MemoryRemBackfillOptions) {
const { config: cfg, diagnostics } = await loadMemoryCommandConfig("memory rem-backfill");
emitMemorySecretResolveDiagnostics(diagnostics, { json: Boolean(opts.json) });
const agentId = resolveAgent(cfg, opts.agent);
await withMemoryManagerForAgent({
cfg,
agentId,
purpose: "status",
run: async (manager) => {
const status = manager.status();
const workspaceDir = status.workspaceDir?.trim();
const pluginConfig = resolveMemoryPluginConfig(cfg);
const remConfig = resolveMemoryRemDreamingConfig({
pluginConfig,
cfg,
});
if (!workspaceDir) {
defaultRuntime.error("Memory rem-backfill requires a resolvable workspace directory.");
process.exitCode = 1;
return;
}
if (opts.rollback) {
const removed = await removeBackfillDiaryEntries({ workspaceDir });
if (opts.json) {
defaultRuntime.writeJson({
workspaceDir,
rollback: true,
dreamsPath: removed.dreamsPath,
removedEntries: removed.removed,
});
return;
}
defaultRuntime.log(
[
`${colorize(isRich(), theme.heading, "REM Backfill")} ${colorize(isRich(), theme.muted, "(rollback)")}`,
colorize(isRich(), theme.muted, `workspace=${shortenHomePath(workspaceDir)}`),
colorize(isRich(), theme.muted, `dreamsPath=${shortenHomePath(removed.dreamsPath)}`),
colorize(isRich(), theme.muted, `removedEntries=${removed.removed}`),
].join("\n"),
);
return;
}
if (!opts.path) {
defaultRuntime.error("Memory rem-backfill requires --path <file-or-dir> unless using --rollback.");
process.exitCode = 1;
return;
}
const scratchDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-rem-backfill-"));
try {
const sourceFiles = await listHistoricalDailyFiles(opts.path);
if (sourceFiles.length === 0) {
defaultRuntime.error(
`Memory rem-backfill found no YYYY-MM-DD.md files at ${shortenHomePath(path.resolve(opts.path))}.`,
);
process.exitCode = 1;
return;
}
const scratchMemoryDir = path.join(scratchDir, "memory");
await fs.mkdir(scratchMemoryDir, { recursive: true });
const workspaceSourceFiles: string[] = [];
for (const filePath of sourceFiles) {
const dst = path.join(scratchMemoryDir, path.basename(filePath));
await fs.copyFile(filePath, dst);
workspaceSourceFiles.push(dst);
}
const grounded = await previewGroundedRemMarkdown({
workspaceDir: scratchDir,
inputPaths: workspaceSourceFiles,
});
const entries = grounded.files
.map((file) => {
const isoDay = extractIsoDayFromPath(file.path);
if (!isoDay) {
return null;
}
return {
isoDay,
sourcePath: file.path,
bodyLines: groundedMarkdownToDiaryLines(file.renderedMarkdown),
};
})
.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
const written = await writeBackfillDiaryEntries({
workspaceDir,
entries,
timezone: remConfig.timezone,
});
if (opts.json) {
defaultRuntime.writeJson({
workspaceDir,
sourcePath: path.resolve(opts.path),
sourceFiles,
groundedFiles: grounded.scannedFiles,
writtenEntries: written.written,
replacedEntries: written.replaced,
dreamsPath: written.dreamsPath,
});
return;
}
const rich = isRich();
defaultRuntime.log(
[
`${colorize(rich, theme.heading, "REM Backfill")} ${colorize(rich, theme.muted, `(${agentId})`)}`,
colorize(rich, theme.muted, `workspace=${shortenHomePath(workspaceDir)}`),
colorize(rich, theme.muted, `sourcePath=${shortenHomePath(path.resolve(opts.path))}`),
colorize(rich, theme.muted, `historicalFiles=${sourceFiles.length} writtenEntries=${written.written} replacedEntries=${written.replaced}`),
colorize(rich, theme.muted, `dreamsPath=${shortenHomePath(written.dreamsPath)}`),
].join("\n"),
);
} finally {
await fs.rm(scratchDir, { recursive: true, force: true });
}
},
});
}

View File

@@ -948,6 +948,177 @@ describe("memory cli", () => {
});
});
it("previews rem harness output from a historical daily file path", async () => {
await withTempWorkspace(async (workspaceDir) => {
const historyDir = path.join(workspaceDir, "history");
await fs.mkdir(historyDir, { recursive: true });
const historyPath = path.join(historyDir, "2025-01-01.md");
await fs.writeFile(
historyPath,
[
"# Preferences Learned",
'- Always use "Happy Together" calendar for flights and reservations.',
"- Calendar ID: udolnrooml2f2ha8jaio24v1r8@group.calendar.google.com",
].join("\n") + "\n",
"utf-8",
);
const close = vi.fn(async () => {});
mockManager({
status: () => makeMemoryStatus({ workspaceDir }),
close,
});
const writeJson = spyRuntimeJson(defaultRuntime);
await runMemoryCli(["rem-harness", "--json", "--path", historyPath]);
const payload = firstWrittenJsonArg<{
sourcePath?: string | null;
sourceFiles?: string[];
historicalImport?: { importedFileCount?: number; importedSignalCount?: number } | null;
rem?: { candidateTruths?: Array<{ snippet?: string }> };
deep?: { candidates?: Array<{ snippet?: string; path?: string }> };
}>(writeJson);
expect(payload?.sourcePath).toBe(historyPath);
expect(payload?.sourceFiles).toEqual([historyPath]);
expect(payload?.historicalImport?.importedFileCount).toBe(1);
expect(payload?.historicalImport?.importedSignalCount).toBeGreaterThan(0);
expect(Array.isArray(payload?.rem?.candidateTruths)).toBe(true);
expect(payload?.deep?.candidates?.[0]?.snippet).toContain("Happy Together");
expect(payload?.deep?.candidates?.[0]?.path).toBe("memory/2025-01-01.md");
expect(close).toHaveBeenCalled();
});
});
it("previews grounded rem output from a historical daily file path", async () => {
await withTempWorkspace(async (workspaceDir) => {
const historyDir = path.join(workspaceDir, "history");
await fs.mkdir(historyDir, { recursive: true });
const historyPath = path.join(historyDir, "2025-01-01.md");
await fs.writeFile(
historyPath,
[
"## Preferences Learned",
'- Always use "Happy Together" calendar for flights and reservations.',
"- Calendar ID: udolnrooml2f2ha8jaio24v1r8@group.calendar.google.com",
"",
"## Setup",
"- Set up Gmail access via gog.",
].join("\n") + "\n",
"utf-8",
);
const close = vi.fn(async () => {});
mockManager({
status: () => makeMemoryStatus({ workspaceDir }),
close,
});
const writeJson = spyRuntimeJson(defaultRuntime);
await runMemoryCli(["rem-harness", "--json", "--grounded", "--path", historyPath]);
const payload = firstWrittenJsonArg<{
grounded?: {
scannedFiles?: number;
files?: Array<{
path?: string;
renderedMarkdown?: string;
memoryImplications?: Array<{ text?: string }>;
}>;
} | null;
}>(writeJson);
expect(payload?.grounded?.scannedFiles).toBe(1);
expect(payload?.grounded?.files?.[0]?.path).toBe("memory/2025-01-01.md");
expect(payload?.grounded?.files?.[0]?.renderedMarkdown).toContain("## What Happened");
expect(payload?.grounded?.files?.[0]?.renderedMarkdown).toContain("## Reflections");
expect(payload?.grounded?.files?.[0]?.renderedMarkdown).toContain(
"## Possible Lasting Updates",
);
expect(payload?.grounded?.files?.[0]?.memoryImplications?.[0]?.text).toContain(
'Always use "Happy Together" calendar for flights and reservations',
);
expect(close).toHaveBeenCalled();
});
});
it("writes grounded rem backfill entries into DREAMS.md", async () => {
await withTempWorkspace(async (workspaceDir) => {
const historyDir = path.join(workspaceDir, "history");
await fs.mkdir(historyDir, { recursive: true });
const historyPath = path.join(historyDir, "2025-01-01.md");
await fs.writeFile(
historyPath,
[
"## Preferences Learned",
'- Always use "Happy Together" calendar for flights and reservations.',
"- Calendar ID: udolnrooml2f2ha8jaio24v1r8@group.calendar.google.com",
].join("\n") + "\n",
"utf-8",
);
const close = vi.fn(async () => {});
mockManager({
status: () => makeMemoryStatus({ workspaceDir }),
close,
});
await runMemoryCli(["rem-backfill", "--path", historyPath]);
const dreams = await fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8");
expect(dreams).toContain("openclaw:dreaming:backfill-entry");
expect(dreams).toContain("January 1, 2025");
expect(dreams).toContain("What Happened");
expect(dreams).toContain("Possible Lasting Updates");
expect(dreams).toContain("Happy Together");
expect(close).toHaveBeenCalled();
});
});
it("rolls back grounded rem backfill entries from DREAMS.md", async () => {
await withTempWorkspace(async (workspaceDir) => {
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
await fs.writeFile(
dreamsPath,
[
"# Dream Diary",
"",
"<!-- openclaw:dreaming:diary:start -->",
"---",
"",
"*April 5, 2026, 3:00 AM*",
"",
"Keep this normal dream.",
"",
"---",
"",
"*January 1, 2025*",
"",
"<!-- openclaw:dreaming:backfill-entry day=2025-01-01 source=memory/2025-01-01.md -->",
"",
"What Happened",
"1. Remove this entry.",
"",
"<!-- openclaw:dreaming:diary:end -->",
"",
].join("\n"),
"utf-8",
);
const close = vi.fn(async () => {});
mockManager({
status: () => makeMemoryStatus({ workspaceDir }),
close,
});
await runMemoryCli(["rem-backfill", "--rollback"]);
const dreams = await fs.readFile(dreamsPath, "utf-8");
expect(dreams).toContain("Keep this normal dream.");
expect(dreams).not.toContain("Remove this entry.");
expect(close).toHaveBeenCalled();
});
});
it("applies top promote candidates into MEMORY.md", async () => {
await withTempWorkspace(async (workspaceDir) => {
await writeDailyMemoryNote(workspaceDir, "2026-04-01", [

View File

@@ -8,6 +8,7 @@ import type {
MemoryCommandOptions,
MemoryPromoteCommandOptions,
MemoryPromoteExplainOptions,
MemoryRemBackfillOptions,
MemoryRemHarnessOptions,
MemorySearchCommandOptions,
} from "./cli.types.js";
@@ -59,6 +60,11 @@ async function runMemoryRemHarness(opts: MemoryRemHarnessOptions) {
await runtime.runMemoryRemHarness(opts);
}
async function runMemoryRemBackfill(opts: MemoryRemBackfillOptions) {
const runtime = await loadMemoryCliRuntime();
await runtime.runMemoryRemBackfill(opts);
}
export function registerMemoryCli(program: Command) {
const memory = program
.command("memory")
@@ -95,6 +101,10 @@ export function registerMemoryCli(program: Command) {
"openclaw memory rem-harness --json",
"Preview REM reflections, candidate truths, and deep promotion output.",
],
[
"openclaw memory rem-backfill --path ./memory",
"Write grounded historical REM entries into DREAMS.md for UI review.",
],
["openclaw memory status --json", "Output machine-readable JSON (good for scripts)."],
])}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/memory", "docs.openclaw.ai/cli/memory")}\n`,
);
@@ -177,9 +187,22 @@ export function registerMemoryCli(program: Command) {
.command("rem-harness")
.description("Preview REM reflections, candidate truths, and deep promotions without writing")
.option("--agent <id>", "Agent id (default: default agent)")
.option("--path <file-or-dir>", "Seed the harness from historical daily memory file(s)")
.option("--grounded", "Also render a grounded day-level REM preview")
.option("--include-promoted", "Include already promoted deep candidates", false)
.option("--json", "Print JSON")
.action(async (opts: MemoryRemHarnessOptions) => {
await runMemoryRemHarness(opts);
});
memory
.command("rem-backfill")
.description("Write grounded historical REM summaries into DREAMS.md for UI review")
.option("--agent <id>", "Agent id (default: default agent)")
.option("--path <file-or-dir>", "Historical daily memory file(s) or directory")
.option("--rollback", "Remove previously written grounded REM backfill entries", false)
.option("--json", "Print JSON")
.action(async (opts: MemoryRemBackfillOptions) => {
await runMemoryRemBackfill(opts);
});
}

View File

@@ -29,4 +29,11 @@ export type MemoryPromoteExplainOptions = MemoryCommandOptions & {
export type MemoryRemHarnessOptions = MemoryCommandOptions & {
includePromoted?: boolean;
path?: string;
grounded?: boolean;
};
export type MemoryRemBackfillOptions = MemoryCommandOptions & {
path?: string;
rollback?: boolean;
};

View File

@@ -3,12 +3,16 @@ import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
appendNarrativeEntry,
buildBackfillDiaryEntry,
buildDiaryEntry,
buildNarrativePrompt,
extractNarrativeText,
formatNarrativeDate,
formatBackfillDiaryDate,
generateAndAppendDreamNarrative,
removeBackfillDiaryEntries,
type NarrativePhaseData,
writeBackfillDiaryEntries,
} from "./dreaming-narrative.js";
import { createMemoryCoreTestHarness } from "./test-helpers.js";
@@ -117,6 +121,88 @@ describe("buildDiaryEntry", () => {
});
});
describe("backfill diary entries", () => {
it("formats a backfill date without time", () => {
expect(formatBackfillDiaryDate("2026-01-01", "UTC")).toBe("January 1, 2026");
});
it("builds a marked backfill diary entry", () => {
const entry = buildBackfillDiaryEntry({
isoDay: "2026-01-01",
sourcePath: "memory/2026-01-01.md",
bodyLines: ["What Happened", "1. A durable preference appeared."],
timezone: "UTC",
});
expect(entry).toContain("*January 1, 2026*");
expect(entry).toContain("openclaw:dreaming:backfill-entry");
expect(entry).toContain("What Happened");
});
it("writes and replaces backfill diary entries", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-backfill-");
const first = await writeBackfillDiaryEntries({
workspaceDir,
timezone: "UTC",
entries: [
{
isoDay: "2026-01-01",
sourcePath: "memory/2026-01-01.md",
bodyLines: ["What Happened", "1. First pass."],
},
],
});
expect(first.written).toBe(1);
expect(first.replaced).toBe(0);
const second = await writeBackfillDiaryEntries({
workspaceDir,
timezone: "UTC",
entries: [
{
isoDay: "2026-01-02",
sourcePath: "memory/2026-01-02.md",
bodyLines: ["Reflections", "1. Second pass."],
},
],
});
expect(second.written).toBe(1);
expect(second.replaced).toBe(1);
const content = await fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8");
expect(content).not.toContain("First pass.");
expect(content).toContain("Second pass.");
expect(content.match(/openclaw:dreaming:backfill-entry/g)?.length).toBe(1);
});
it("removes only backfill diary entries", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-backfill-");
await appendNarrativeEntry({
workspaceDir,
narrative: "Keep this real dream.",
nowMs: Date.parse("2026-04-05T03:00:00Z"),
timezone: "UTC",
});
await writeBackfillDiaryEntries({
workspaceDir,
timezone: "UTC",
entries: [
{
isoDay: "2026-01-01",
sourcePath: "memory/2026-01-01.md",
bodyLines: ["What Happened", "1. Remove this backfill."],
},
],
});
const removed = await removeBackfillDiaryEntries({ workspaceDir });
expect(removed.removed).toBe(1);
const content = await fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8");
expect(content).toContain("Keep this real dream.");
expect(content).not.toContain("Remove this backfill.");
});
});
describe("appendNarrativeEntry", () => {
it("creates DREAMS.md with diary header on fresh workspace", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");

View File

@@ -70,6 +70,7 @@ const NARRATIVE_TIMEOUT_MS = 60_000;
const DREAMS_FILENAMES = ["DREAMS.md", "dreams.md"] as const;
const DIARY_START_MARKER = "<!-- openclaw:dreaming:diary:start -->";
const DIARY_END_MARKER = "<!-- openclaw:dreaming:diary:end -->";
const BACKFILL_ENTRY_MARKER = "openclaw:dreaming:backfill-entry";
// ── Prompt building ────────────────────────────────────────────────────
@@ -167,6 +168,157 @@ async function resolveDreamsPath(workspaceDir: string): Promise<string> {
return path.join(workspaceDir, DREAMS_FILENAMES[0]);
}
async function readDreamsFile(dreamsPath: string): Promise<string> {
try {
return await fs.readFile(dreamsPath, "utf-8");
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
return "";
}
throw err;
}
}
function ensureDiarySection(existing: string): string {
if (existing.includes(DIARY_START_MARKER) && existing.includes(DIARY_END_MARKER)) {
return existing;
}
const diarySection = `# Dream Diary\n\n${DIARY_START_MARKER}\n${DIARY_END_MARKER}\n`;
if (existing.trim().length === 0) {
return diarySection;
}
return diarySection + "\n" + existing;
}
function replaceDiaryContent(existing: string, diaryContent: string): string {
const ensured = ensureDiarySection(existing);
const startIdx = ensured.indexOf(DIARY_START_MARKER);
const endIdx = ensured.indexOf(DIARY_END_MARKER);
if (startIdx < 0 || endIdx < 0 || endIdx < startIdx) {
return ensured;
}
const before = ensured.slice(0, startIdx + DIARY_START_MARKER.length);
const after = ensured.slice(endIdx);
const normalized = diaryContent.trim().length > 0 ? `\n${diaryContent.trim()}\n` : "\n";
return before + normalized + after;
}
function splitDiaryBlocks(diaryContent: string): string[] {
return diaryContent
.split(/\n---\n/)
.map((block) => block.trim())
.filter((block) => block.length > 0);
}
function joinDiaryBlocks(blocks: string[]): string {
if (blocks.length === 0) {
return "";
}
return blocks.map((block) => `---\n\n${block.trim()}\n`).join("\n");
}
function stripBackfillDiaryBlocks(existing: string): { updated: string; removed: number } {
const ensured = ensureDiarySection(existing);
const startIdx = ensured.indexOf(DIARY_START_MARKER);
const endIdx = ensured.indexOf(DIARY_END_MARKER);
if (startIdx < 0 || endIdx < 0 || endIdx < startIdx) {
return { updated: ensured, removed: 0 };
}
const inner = ensured.slice(startIdx + DIARY_START_MARKER.length, endIdx);
const kept: string[] = [];
let removed = 0;
for (const block of splitDiaryBlocks(inner)) {
if (block.includes(BACKFILL_ENTRY_MARKER)) {
removed += 1;
continue;
}
kept.push(block);
}
return {
updated: replaceDiaryContent(ensured, joinDiaryBlocks(kept)),
removed,
};
}
export function formatBackfillDiaryDate(isoDay: string, timezone?: string): string {
const opts: Intl.DateTimeFormatOptions = {
timeZone: timezone ?? "UTC",
year: "numeric",
month: "long",
day: "numeric",
};
const epochMs = Date.parse(`${isoDay}T12:00:00Z`);
return new Intl.DateTimeFormat("en-US", opts).format(new Date(epochMs));
}
export function buildBackfillDiaryEntry(params: {
isoDay: string;
bodyLines: string[];
sourcePath?: string;
timezone?: string;
}): string {
const dateStr = formatBackfillDiaryDate(params.isoDay, params.timezone);
const marker = `<!-- ${BACKFILL_ENTRY_MARKER} day=${params.isoDay}${params.sourcePath ? ` source=${params.sourcePath}` : ""} -->`;
const body = params.bodyLines.map((line) => line.trimEnd()).join("\n").trim();
return [`*${dateStr}*`, marker, body].filter((part) => part.length > 0).join("\n\n");
}
export async function writeBackfillDiaryEntries(params: {
workspaceDir: string;
entries: Array<{
isoDay: string;
bodyLines: string[];
sourcePath?: string;
}>;
timezone?: string;
}): Promise<{ dreamsPath: string; written: number; replaced: number }> {
const dreamsPath = await resolveDreamsPath(params.workspaceDir);
await fs.mkdir(path.dirname(dreamsPath), { recursive: true });
const existing = await readDreamsFile(dreamsPath);
const stripped = stripBackfillDiaryBlocks(existing);
const startIdx = stripped.updated.indexOf(DIARY_START_MARKER);
const endIdx = stripped.updated.indexOf(DIARY_END_MARKER);
const inner =
startIdx >= 0 && endIdx > startIdx
? stripped.updated.slice(startIdx + DIARY_START_MARKER.length, endIdx)
: "";
const preservedBlocks = splitDiaryBlocks(inner);
const nextBlocks = [
...preservedBlocks,
...params.entries.map((entry) =>
buildBackfillDiaryEntry({
isoDay: entry.isoDay,
bodyLines: entry.bodyLines,
sourcePath: entry.sourcePath,
timezone: params.timezone,
}),
),
];
const updated = replaceDiaryContent(stripped.updated, joinDiaryBlocks(nextBlocks));
await fs.writeFile(dreamsPath, updated, "utf-8");
return {
dreamsPath,
written: params.entries.length,
replaced: stripped.removed,
};
}
export async function removeBackfillDiaryEntries(params: {
workspaceDir: string;
}): Promise<{ dreamsPath: string; removed: number }> {
const dreamsPath = await resolveDreamsPath(params.workspaceDir);
const existing = await readDreamsFile(dreamsPath);
const stripped = stripBackfillDiaryBlocks(existing);
if (stripped.removed > 0 || existing.length > 0) {
await fs.mkdir(path.dirname(dreamsPath), { recursive: true });
await fs.writeFile(dreamsPath, stripped.updated, "utf-8");
}
return {
dreamsPath,
removed: stripped.removed,
};
}
export function buildDiaryEntry(narrative: string, dateStr: string): string {
return `\n---\n\n*${dateStr}*\n\n${narrative}\n`;
}

View File

@@ -18,18 +18,9 @@ import {
type MemoryLightDreamingConfig,
type MemoryRemDreamingConfig,
} from "openclaw/plugin-sdk/memory-core-host-status";
import {
lowercasePreservingWhitespace,
normalizeLowercaseStringOrEmpty,
} from "openclaw/plugin-sdk/text-runtime";
import { writeDailyDreamingPhaseBlock } from "./dreaming-markdown.js";
import { generateAndAppendDreamNarrative, type NarrativePhaseData } from "./dreaming-narrative.js";
import {
asRecord,
formatErrorMessage,
includesSystemEventToken,
normalizeTrimmedString,
} from "./dreaming-shared.js";
import { asRecord, formatErrorMessage, normalizeTrimmedString } from "./dreaming-shared.js";
import {
readShortTermRecallEntries,
recordDreamingPhaseSignals,
@@ -131,7 +122,7 @@ function isGenericDailyHeading(heading: string): boolean {
if (!normalized) {
return true;
}
const lower = normalizeLowercaseStringOrEmpty(normalized);
const lower = normalized.toLowerCase();
if (lower === "today" || lower === "yesterday" || lower === "tomorrow") {
return true;
}
@@ -428,7 +419,7 @@ type SessionIngestionCollectionResult = {
function normalizeWorkspaceKey(workspaceDir: string): string {
const resolved = path.resolve(workspaceDir).replace(/\\/g, "/");
return process.platform === "win32" ? lowercasePreservingWhitespace(resolved) : resolved;
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
}
function resolveSessionIngestionStatePath(workspaceDir: string): string {
@@ -1100,13 +1091,117 @@ async function ingestDailyMemorySignals(params: {
}
}
export async function seedHistoricalDailyMemorySignals(params: {
workspaceDir: string;
filePaths: string[];
limit: number;
nowMs: number;
timezone?: string;
}): Promise<{
importedFileCount: number;
importedSignalCount: number;
skippedPaths: string[];
}> {
const normalizedPaths = [...new Set(params.filePaths.map((entry) => entry.trim()).filter(Boolean))];
if (normalizedPaths.length === 0) {
return {
importedFileCount: 0,
importedSignalCount: 0,
skippedPaths: [],
};
}
const resolved = normalizedPaths
.map((filePath) => {
const fileName = path.basename(filePath);
const match = fileName.match(DAILY_MEMORY_FILENAME_RE);
if (!match) {
return { filePath, day: null as string | null };
}
return { filePath, day: match[1] ?? null };
})
.toSorted((a, b) => {
if (a.day && b.day) {
return b.day.localeCompare(a.day);
}
if (a.day) {
return -1;
}
if (b.day) {
return 1;
}
return a.filePath.localeCompare(b.filePath);
});
const valid = resolved.filter((entry): entry is { filePath: string; day: string } => Boolean(entry.day));
const skippedPaths = resolved.filter((entry) => !entry.day).map((entry) => entry.filePath);
const totalCap = Math.max(20, params.limit * 4);
const perFileCap = Math.max(6, Math.ceil(totalCap / Math.max(1, valid.length)));
let importedSignalCount = 0;
let importedFileCount = 0;
for (const entry of valid) {
if (importedSignalCount >= totalCap) {
break;
}
const raw = await fs.readFile(entry.filePath, "utf-8").catch((err: unknown) => {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
skippedPaths.push(entry.filePath);
return "";
}
throw err;
});
if (!raw) {
continue;
}
const lines = stripManagedDailyDreamingLines(raw.split(/\r?\n/));
const chunks = buildDailySnippetChunks(lines, perFileCap);
const results: MemorySearchResult[] = [];
for (const chunk of chunks) {
results.push({
path: `memory/${entry.day}.md`,
startLine: chunk.startLine,
endLine: chunk.endLine,
score: DAILY_INGESTION_SCORE,
snippet: chunk.snippet,
source: "memory",
});
if (results.length >= perFileCap || importedSignalCount + results.length >= totalCap) {
break;
}
}
if (results.length === 0) {
continue;
}
await recordShortTermRecalls({
workspaceDir: params.workspaceDir,
query: `__dreaming_daily__:${entry.day}`,
results,
signalType: "daily",
dedupeByQueryPerDay: true,
dayBucket: entry.day,
nowMs: params.nowMs,
timezone: params.timezone,
});
importedSignalCount += results.length;
importedFileCount += 1;
}
return {
importedFileCount,
importedSignalCount,
skippedPaths,
};
}
function entryAverageScore(entry: ShortTermRecallEntry): number {
return entry.recallCount > 0 ? Math.max(0, Math.min(1, entry.totalScore / entry.recallCount)) : 0;
}
function tokenizeSnippet(snippet: string): Set<string> {
return new Set(
normalizeLowercaseStringOrEmpty(snippet)
snippet
.toLowerCase()
.split(/[^a-z0-9]+/i)
.map((token) => token.trim())
.filter(Boolean),
@@ -1117,7 +1212,7 @@ function jaccardSimilarity(left: string, right: string): number {
const leftTokens = tokenizeSnippet(left);
const rightTokens = tokenizeSnippet(right);
if (leftTokens.size === 0 || rightTokens.size === 0) {
return normalizeLowercaseStringOrEmpty(left) === normalizeLowercaseStringOrEmpty(right) ? 1 : 0;
return left.trim().toLowerCase() === right.trim().toLowerCase() ? 1 : 0;
}
let intersection = 0;
for (const token of leftTokens) {
@@ -1529,10 +1624,7 @@ async function runPhaseIfTriggered(params: {
storage: { mode: "inline" | "separate" | "both"; separateReports: boolean };
});
}): Promise<{ handled: true; reason: string } | undefined> {
if (
params.trigger !== "heartbeat" ||
!includesSystemEventToken(params.cleanedBody, params.eventText)
) {
if (params.trigger !== "heartbeat" || params.cleanedBody.trim() !== params.eventText) {
return undefined;
}
if (!params.config.enabled) {
@@ -1558,10 +1650,7 @@ async function runPhaseIfTriggered(params: {
await runLightDreaming({
workspaceDir,
cfg: params.cfg,
config: params.config as MemoryLightDreamingConfig & {
timezone?: string;
storage: { mode: "inline" | "separate" | "both"; separateReports: boolean };
},
config: params.config,
logger: params.logger,
subagent: params.subagent,
});
@@ -1569,10 +1658,7 @@ async function runPhaseIfTriggered(params: {
await runRemDreaming({
workspaceDir,
cfg: params.cfg,
config: params.config as MemoryRemDreamingConfig & {
timezone?: string;
storage: { mode: "inline" | "separate" | "both"; separateReports: boolean };
},
config: params.config,
logger: params.logger,
subagent: params.subagent,
});

View File

@@ -0,0 +1,903 @@
import fs from "node:fs/promises";
import path from "node:path";
const REM_BLOCKED_SECTION_RE =
/\b(morning reminders|tasks? for today|to-?do|pickups?|action items?|next steps?|open questions?|stats|setup tasks?|priority contacts|visitors?|top priority candidates|timeline coverage|action items for morning review|test .* skill|heartbeat checks?|date semantics guardrail|still broken|last message (?:&|and) status|plugin \/ service warning|email triage cron)\b/i;
const REM_GENERIC_SECTION_RE =
/^(setup|session notes?|notes|summary|major accomplishments?|infrastructure|process improvements?)$/i;
const REM_MEMORY_SIGNAL_RE =
/\b(always use|prefers?|preference|preferences|standing rule|rule:|use .* calendar|durable|remember)\b/i;
const REM_BUILD_SIGNAL_RE =
/\b(set up|setup|created|built|rewrite|rewrote|implemented|installed|configured|added|updated|exported|documented)\b/i;
const REM_INCIDENT_SIGNAL_RE =
/\b(fail(?:ed|ing)?|error|issue|problem|auth|expired|broken|unable|missing|required|root cause|consecutive failures?)\b/i;
const REM_LOGISTICS_SIGNAL_RE =
/\b(visitor|arriv(?:e|al|ing)|flight|calendar|reservation|schedule|coordinate|travel|pickup)\b/i;
const REM_TASK_SIGNAL_RE =
/\b(reminder|task|to-?do|action item|next step|need to|follow up|respond to|call\b|check\b)\b/i;
const REM_ROUTING_SIGNAL_RE =
/\b(categor(?:ize|ized|ization)|route|routing|workflow|processor|read later|auto-implement|codex|razor)\b/i;
const REM_OPERATOR_RULE_SIGNAL_RE = /\b(learned:|rule:|always [a-z])\b/i;
const REM_EXTERNALIZATION_SIGNAL_RE =
/\b(obsidian|memory|tracker|notes captured|committed to memory|updated .*md|documented|file comparison table)\b/i;
const REM_RETRY_SIGNAL_RE =
/\b(repeat(?:ed|edly)?|again|retry|root cause|third attempt|fourth|fifth|consecutive failures?)\b/i;
const REM_PERSON_PATTERN_SIGNAL_RE =
/\b(relationship|who:|patterns?:|failure modes?:|best stance:|space|boundaries|timing|family quick reference)\b/i;
const REM_SITUATIONAL_SIGNAL_RE =
/\b(hotel|address|phone|reservation|check-?in|check-?out|flight|arrival|departure|terminal|price shown|invoice|pending items|screenshot|butler)\b/i;
const REM_PERSISTENCE_SIGNAL_RE =
/\b(always|preference|prefers?|standing rule|best stance|failure modes?|key patterns?|relationship|who:|important .* keep track|people in .* life|partner|wife|husband|boyfriend|girlfriend)\b/i;
const REM_TRANSIENT_SIGNAL_RE =
/\b(today|this session|in progress|installed|booked|confirmed|pending|status:|action pending|open items?|next steps?|issue:|diagnostics|screenshot|source file|insight files|thread\b|ticket|price shown|calendar fix|cron fixes|security audit|updates? this session|bought:|order\b)\b/i;
const REM_SECTION_PERSISTENCE_TITLE_RE =
/\b(preferences? learned|preference|people update|relationship|standing|patterns?|identity|memory)\b/i;
const REM_SECTION_TRANSIENT_TITLE_RE =
/\b(setup|fix|fixes|audit|booked|call|today|session|updates?|file paths|open items?|next steps?|research pipeline|info gathered|calendar|tickets?)\b/i;
const REM_METADATA_HEAVY_SIGNAL_RE =
/\b(address|phone|email|website|google maps|source file|insight files|conversation id|thread has|order\b|reservation\b|price\b|cost\b|ticket|uuid|url:|model:|workspace:|bindings:|accountid|config change|path:)\b/i;
const REM_PROJECT_META_SIGNAL_RE =
/\b(strategy|audit|discussion|research|topic|candidate|north star|pipeline|data dump|export|draft|insights? draft|weekly|analysis|findings)\b/i;
const REM_PROCESS_FRAME_SIGNAL_RE =
/\b(dossier|registry|cadence|framework|facts,\s*timeline|open loops|next actions|auto preference rollups?|insights? draft created)\b/i;
const REM_TOOLING_META_SIGNAL_RE =
/\b(cli|tool|tools\.md|agents\.md|sessionssend|subagents?|spawn|tmux|xurl|bird|codex exec|interactive codex)\b/i;
const REM_TRAVEL_DECISION_SIGNAL_RE =
/\b(routing|cabin|business class|trip brief|departure|arrival|hotel|reservation|tickets?|show tonight|cheaper alternatives?|venue timing)\b/i;
const REM_STABLE_PERSON_SIGNAL_RE =
/\b(partner|wife|husband|boyfriend|girlfriend|relationship interest|lives in)\b/i;
const REM_EXPLICIT_PREFERENCE_SIGNAL_RE =
/\b(explicitly|wants?|does not want|don't want|default .* should|should default to|likes?|dislikes?|treat .* as|prefers?)\b/i;
const REM_SPECIFICITY_BURDEN_RE =
/\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\b|€|\$\d|→|\b\d{1,2}:\d{2}\b|\+\d{6,}/i;
const REM_TIME_PREFIX_RE = /^\d{1,2}:\d{2}\s*-\s*/;
const REM_CODE_FENCE_RE = /^\s*```/;
const REM_TABLE_RE = /^\s*\|.*\|\s*$/;
const REM_TABLE_DIVIDER_RE = /^\s*\|?[\s:-]+\|[\s|:-]*$/;
const REM_SUMMARY_FACT_LIMIT = 4;
const REM_SUMMARY_REFLECTION_LIMIT = 4;
const REM_SUMMARY_MEMORY_LIMIT = 3;
export type GroundedRemPreviewItem = {
text: string;
refs: string[];
};
export type GroundedRemCandidate = GroundedRemPreviewItem & {
lean: "likely_durable" | "unclear" | "likely_situational";
};
export type GroundedRemFilePreview = {
path: string;
facts: GroundedRemPreviewItem[];
reflections: GroundedRemPreviewItem[];
memoryImplications: GroundedRemPreviewItem[];
candidates: GroundedRemCandidate[];
renderedMarkdown: string;
};
export type GroundedRemPreviewResult = {
workspaceDir: string;
scannedFiles: number;
files: GroundedRemFilePreview[];
};
type CandidateSnippetSummary = GroundedRemCandidate & {
score: number;
};
type ParsedSectionLine = {
line: number;
text: string;
};
type ParsedMarkdownSection = {
title: string;
startLine: number;
endLine: number;
lines: ParsedSectionLine[];
};
type SectionSnippet = {
text: string;
line: number;
};
type SectionSummary = {
title: string;
text: string;
refs: string[];
scores: {
preference: number;
build: number;
incident: number;
logistics: number;
tasks: number;
routing: number;
externalization: number;
retries: number;
overall: number;
};
};
function normalizeWhitespace(value: string): string {
return value.trim().replace(/\s+/g, " ");
}
function normalizePath(rawPath: string): string {
return rawPath.replaceAll("\\", "/").replace(/^\.\//, "");
}
function stripMarkdown(text: string): string {
return normalizeWhitespace(
text
.replace(/!\[[^\]]*]\([^)]*\)/g, "")
.replace(/\[([^\]]+)]\([^)]*\)/g, "$1")
.replace(/[`*_~>#]/g, "")
.replace(/\s+/g, " "),
);
}
function sanitizeSectionTitle(title: string): string {
return normalizeWhitespace(stripMarkdown(title).replace(REM_TIME_PREFIX_RE, ""));
}
function makeRef(pathValue: string, startLine: number, endLine = startLine): string {
return startLine === endLine
? `${pathValue}:${startLine}`
: `${pathValue}:${startLine}-${endLine}`;
}
function parseMarkdownSections(content: string): ParsedMarkdownSection[] {
const sections: ParsedMarkdownSection[] = [];
const lines = content.split(/\r?\n/);
let current: ParsedMarkdownSection | null = null;
let inCodeFence = false;
const flush = () => {
if (!current) {
return;
}
const meaningfulLines = current.lines.filter(
(entry) => normalizeWhitespace(entry.text).length > 0,
);
if (meaningfulLines.length > 0) {
const endLine = meaningfulLines[meaningfulLines.length - 1]?.line ?? current.endLine;
sections.push({ ...current, endLine, lines: meaningfulLines });
}
current = null;
};
for (let index = 0; index < lines.length; index += 1) {
const rawLine = lines[index] ?? "";
const lineNumber = index + 1;
if (REM_CODE_FENCE_RE.test(rawLine)) {
inCodeFence = !inCodeFence;
continue;
}
if (inCodeFence) {
continue;
}
const headingMatch = rawLine.match(/^\s{0,3}(#{2,6})\s+(.+)$/);
if (headingMatch?.[2]) {
flush();
current = {
title: sanitizeSectionTitle(headingMatch[2]),
startLine: lineNumber,
endLine: lineNumber,
lines: [],
};
continue;
}
if (!current) {
continue;
}
current.endLine = lineNumber;
const trimmed = rawLine.trim();
if (
!trimmed ||
/^---+$/.test(trimmed) ||
REM_TABLE_RE.test(trimmed) ||
REM_TABLE_DIVIDER_RE.test(trimmed)
) {
continue;
}
current.lines.push({ line: lineNumber, text: rawLine });
}
flush();
return sections;
}
function sectionToSnippets(section: ParsedMarkdownSection): SectionSnippet[] {
const snippets: SectionSnippet[] = [];
const seen = new Set<string>();
for (const entry of section.lines) {
const trimmed = entry.text.trim();
if (!trimmed) {
continue;
}
const bulletMatch = trimmed.match(/^(?:[-*+]|\d+\.)\s+(?:\[[ xX]\]\s*)?(.*)$/);
const candidateText = bulletMatch?.[1] ?? trimmed;
const text = normalizeWhitespace(stripMarkdown(candidateText));
if (text.length < 10) {
continue;
}
const dedupeKey = text.toLowerCase();
if (seen.has(dedupeKey)) {
continue;
}
seen.add(dedupeKey);
snippets.push({ text, line: entry.line });
}
return snippets;
}
function countMatchingSnippets(snippets: SectionSnippet[], pattern: RegExp): number {
let count = 0;
for (const snippet of snippets) {
if (pattern.test(snippet.text)) {
count += 1;
}
}
return count;
}
function scoreSection(section: ParsedMarkdownSection, snippets: SectionSnippet[]) {
const title = section.title;
const titleBonus = (pattern: RegExp) => (pattern.test(title) ? 1 : 0);
const preference =
countMatchingSnippets(snippets, REM_MEMORY_SIGNAL_RE) + titleBonus(REM_MEMORY_SIGNAL_RE);
const build =
countMatchingSnippets(snippets, REM_BUILD_SIGNAL_RE) + titleBonus(REM_BUILD_SIGNAL_RE);
const incident =
countMatchingSnippets(snippets, REM_INCIDENT_SIGNAL_RE) + titleBonus(REM_INCIDENT_SIGNAL_RE);
const logistics =
countMatchingSnippets(snippets, REM_LOGISTICS_SIGNAL_RE) + titleBonus(REM_LOGISTICS_SIGNAL_RE);
const tasks =
countMatchingSnippets(snippets, REM_TASK_SIGNAL_RE) + titleBonus(REM_TASK_SIGNAL_RE);
const routing =
countMatchingSnippets(snippets, REM_ROUTING_SIGNAL_RE) + titleBonus(REM_ROUTING_SIGNAL_RE);
const externalization =
countMatchingSnippets(snippets, REM_EXTERNALIZATION_SIGNAL_RE) +
titleBonus(REM_EXTERNALIZATION_SIGNAL_RE);
const retries =
countMatchingSnippets(snippets, REM_RETRY_SIGNAL_RE) + titleBonus(REM_RETRY_SIGNAL_RE);
const overall =
preference * 2 +
build * 1.6 +
incident * 1.6 +
logistics * 1.2 +
routing * 1.8 +
externalization * 1.4 +
Math.min(snippets.length, 3) * 0.3 -
(REM_GENERIC_SECTION_RE.test(title) ? 0.8 : 0);
return {
preference,
build,
incident,
logistics,
tasks,
routing,
externalization,
retries,
overall,
};
}
function scoreSnippet(text: string, title: string): number {
let score = 1;
if (REM_MEMORY_SIGNAL_RE.test(text)) {
score += 2.2;
}
if (REM_BUILD_SIGNAL_RE.test(text)) {
score += 1.2;
}
if (REM_INCIDENT_SIGNAL_RE.test(text)) {
score += 1.2;
}
if (REM_LOGISTICS_SIGNAL_RE.test(text)) {
score += 0.9;
}
if (REM_ROUTING_SIGNAL_RE.test(text)) {
score += 1.4;
}
if (REM_EXTERNALIZATION_SIGNAL_RE.test(text)) {
score += 1.1;
}
if (REM_RETRY_SIGNAL_RE.test(text)) {
score += 0.9;
}
if (REM_TASK_SIGNAL_RE.test(text) && !REM_BUILD_SIGNAL_RE.test(text)) {
score -= 0.8;
}
if (title && !REM_GENERIC_SECTION_RE.test(title)) {
score += 0.25;
}
return score;
}
function chooseSummarySnippets(
section: ParsedMarkdownSection,
snippets: SectionSnippet[],
): SectionSnippet[] {
const selectionLimit = REM_GENERIC_SECTION_RE.test(section.title) ? 2 : 3;
return [...snippets]
.toSorted((left, right) => {
const scoreDelta =
scoreSnippet(right.text, section.title) - scoreSnippet(left.text, section.title);
if (scoreDelta !== 0) {
return scoreDelta;
}
return left.line - right.line;
})
.slice(0, selectionLimit)
.toSorted((left, right) => left.line - right.line);
}
function joinSummaryParts(parts: string[]): string {
if (parts.length <= 1) {
return parts[0] ?? "";
}
if (parts.length === 2) {
return `${parts[0]} and ${parts[1]}`;
}
return `${parts.slice(0, -1).join("; ")}; and ${parts[parts.length - 1]}`;
}
function summarizeSection(
pathValue: string,
section: ParsedMarkdownSection,
): SectionSummary | null {
if (REM_BLOCKED_SECTION_RE.test(section.title)) {
return null;
}
const snippets = sectionToSnippets(section);
if (snippets.length === 0) {
return null;
}
const selected = chooseSummarySnippets(section, snippets);
if (selected.length === 0) {
return null;
}
const title = sanitizeSectionTitle(section.title);
const body = joinSummaryParts(selected.map((snippet) => snippet.text));
const text = !title || REM_GENERIC_SECTION_RE.test(title) ? body : `${title}: ${body}`;
return {
title,
text,
refs: selected.map((snippet) => makeRef(pathValue, snippet.line)),
scores: scoreSection(section, snippets),
};
}
function compactCandidateTitle(title: string): string {
let compact = sanitizeSectionTitle(title)
.replace(/\s*\((?:via:|from qmd \+ memory|this session)[^)]+\)\s*/gi, " ")
.replace(
/\s*[—-]\s*(?:research results.*|in progress.*|working.*|installed.*|booked.*|proposed.*|clarified.*|candidate.*|fixes.*|updates?.*)$/i,
"",
)
.trim();
if (/^(?:preferences? learned|candidate facts?)$/i.test(compact)) {
return "";
}
compact = compact.replace(/^preference:\s*/i, "");
return compact;
}
function compactCandidateSnippetText(text: string, title: string): string {
const normalized = normalizeWhitespace(text);
if (REM_STABLE_PERSON_SIGNAL_RE.test(`${title} ${normalized}`)) {
return (normalized.split(/(?<=[.?!])\s+/)[0] ?? normalized).trim();
}
return normalized;
}
function scoreCandidateSnippet(text: string, title: string): number {
let score = 0;
if (REM_PERSISTENCE_SIGNAL_RE.test(text)) {
score += 3.2;
}
if (REM_MEMORY_SIGNAL_RE.test(text)) {
score += 2.4;
}
if (REM_EXPLICIT_PREFERENCE_SIGNAL_RE.test(text)) {
score += 1.8;
}
if (REM_PERSON_PATTERN_SIGNAL_RE.test(text)) {
score += 2.3;
}
if (REM_OPERATOR_RULE_SIGNAL_RE.test(text)) {
score += 1.6;
}
if (REM_SECTION_PERSISTENCE_TITLE_RE.test(title)) {
score += 1.2;
}
if (REM_STABLE_PERSON_SIGNAL_RE.test(text)) {
score += 1.5;
}
if (REM_METADATA_HEAVY_SIGNAL_RE.test(text)) {
score -= 2.4;
}
if (REM_PROJECT_META_SIGNAL_RE.test(`${title} ${text}`)) {
score -= 2.2;
}
if (REM_PROCESS_FRAME_SIGNAL_RE.test(text)) {
score -= 2.4;
}
if (REM_TOOLING_META_SIGNAL_RE.test(text) && !REM_STABLE_PERSON_SIGNAL_RE.test(text)) {
score -= 2.1;
}
if (REM_TRAVEL_DECISION_SIGNAL_RE.test(text)) {
score -= 2.6;
}
if (REM_SPECIFICITY_BURDEN_RE.test(text) && !REM_STABLE_PERSON_SIGNAL_RE.test(text)) {
score -= 1.2;
}
if (REM_SITUATIONAL_SIGNAL_RE.test(text)) {
score -= 2.8;
}
if (REM_TRANSIENT_SIGNAL_RE.test(text)) {
score -= 2;
}
if (REM_INCIDENT_SIGNAL_RE.test(text)) {
score -= 1.6;
}
if (REM_TASK_SIGNAL_RE.test(text)) {
score -= 1.2;
}
if (REM_LOGISTICS_SIGNAL_RE.test(text) && !REM_MEMORY_SIGNAL_RE.test(text)) {
score -= 1.4;
}
if (REM_BUILD_SIGNAL_RE.test(text) && !REM_MEMORY_SIGNAL_RE.test(text)) {
score -= 0.8;
}
if (REM_SECTION_TRANSIENT_TITLE_RE.test(title) && !REM_SECTION_PERSISTENCE_TITLE_RE.test(title)) {
score -= 1.2;
}
if (/[`/]/.test(text) || /https?:\/\//i.test(text)) {
score -= 0.8;
}
return score;
}
function chooseFactSnippets(
section: ParsedMarkdownSection,
snippets: SectionSnippet[],
): SectionSnippet[] {
return [...snippets]
.map((snippet) => {
const text = compactCandidateSnippetText(snippet.text, section.title);
const score =
scoreCandidateSnippet(text, section.title) + (REM_MEMORY_SIGNAL_RE.test(text) ? 0.6 : 0);
return { snippet: { ...snippet, text }, score };
})
.filter((entry) => entry.snippet.text.length >= 18 && entry.score >= 1.4)
.toSorted((left, right) => {
if (right.score !== left.score) {
return right.score - left.score;
}
return left.snippet.line - right.snippet.line;
})
.slice(0, 2)
.map((entry) => entry.snippet)
.toSorted((left, right) => left.line - right.line);
}
type FactSnippetSummary = GroundedRemPreviewItem & {
score: number;
};
function buildFactText(title: string, text: string): string {
const compactTitle = compactCandidateTitle(title);
if (!compactTitle) {
return text;
}
if (
REM_SECTION_PERSISTENCE_TITLE_RE.test(compactTitle) ||
REM_STABLE_PERSON_SIGNAL_RE.test(compactTitle) ||
/\b(relationship|people mentioned|people update|identity)\b/i.test(compactTitle)
) {
return `${compactTitle}: ${text}`;
}
return text;
}
function chooseCandidateSnippets(
section: ParsedMarkdownSection,
snippets: SectionSnippet[],
): SectionSnippet[] {
return [...snippets]
.map((snippet) => {
const text = compactCandidateSnippetText(snippet.text, section.title);
const score = scoreCandidateSnippet(text, section.title);
return { snippet: { ...snippet, text }, score };
})
.filter((entry) => entry.snippet.text.length >= 18 && entry.score >= 1.8)
.toSorted((left, right) => {
if (right.score !== left.score) {
return right.score - left.score;
}
return left.snippet.line - right.snippet.line;
})
.slice(0, 2)
.map((entry) => entry.snippet)
.toSorted((left, right) => left.line - right.line);
}
function buildCandidateSnippetText(title: string, text: string): string {
return buildFactText(title, text);
}
function classifyCandidateLeanFromText(text: string, title: string): GroundedRemCandidate["lean"] {
const score = scoreCandidateSnippet(text, title);
if (score >= 4) {
return "likely_durable";
}
if (score <= 0.25 || REM_SITUATIONAL_SIGNAL_RE.test(text) || REM_TRANSIENT_SIGNAL_RE.test(text)) {
return "likely_situational";
}
return "unclear";
}
function addReflection(
reflections: GroundedRemPreviewItem[],
seen: Set<string>,
text: string,
refs: string[],
) {
const normalized = normalizeWhitespace(text);
const key = normalized.toLowerCase();
if (!normalized || seen.has(key)) {
return;
}
seen.add(key);
reflections.push({ text: normalized, refs });
}
function isOperatorRuleSummary(summary: SectionSummary): boolean {
return (
/process improvements?/i.test(summary.title) || REM_OPERATOR_RULE_SIGNAL_RE.test(summary.text)
);
}
function isRoutingSummary(summary: SectionSummary): boolean {
return summary.scores.routing > 0 || REM_ROUTING_SIGNAL_RE.test(summary.text);
}
function previewGroundedRemForFile(params: {
relPath: string;
content: string;
}): GroundedRemFilePreview {
const sections = parseMarkdownSections(params.content);
const sectionScores = sections.map((section) => ({
section,
snippets: sectionToSnippets(section),
}));
const summaries = sectionScores
.map(({ section }) => summarizeSection(params.relPath, section))
.filter((summary): summary is SectionSummary => summary !== null);
const factSummaries: FactSnippetSummary[] = sections.flatMap((section) => {
if (REM_BLOCKED_SECTION_RE.test(section.title)) {
return [];
}
const snippets = sectionToSnippets(section);
if (snippets.length === 0) {
return [];
}
return chooseFactSnippets(section, snippets).map((snippet) => ({
text: buildFactText(section.title, snippet.text),
refs: [makeRef(params.relPath, snippet.line)],
score: scoreCandidateSnippet(snippet.text, section.title),
}));
});
const memoryImplications = summaries
.filter((summary) => summary.scores.preference > 0 || isOperatorRuleSummary(summary))
.map((summary) => ({
text: summary.text.replace(/^[^:]+:\s*/, ""),
refs: summary.refs,
}))
.filter((item, index, items) => items.findIndex((entry) => entry.text === item.text) === index)
.slice(0, REM_SUMMARY_MEMORY_LIMIT);
const candidateSnippets: CandidateSnippetSummary[] = sections.flatMap((section) => {
if (REM_BLOCKED_SECTION_RE.test(section.title)) {
return [];
}
const snippets = sectionToSnippets(section);
if (snippets.length === 0) {
return [];
}
return chooseCandidateSnippets(section, snippets)
.map((snippet) => {
const score = scoreCandidateSnippet(snippet.text, section.title);
const text = buildCandidateSnippetText(section.title, snippet.text);
return {
text,
refs: [makeRef(params.relPath, snippet.line)],
lean: classifyCandidateLeanFromText(snippet.text, section.title),
score,
};
})
.filter((candidate) => candidate.text.length >= 12 && candidate.score >= 1.8);
});
const candidates = candidateSnippets
.toSorted((left, right) => {
const leanRank = { likely_durable: 0, unclear: 1, likely_situational: 2 };
const leanDelta = leanRank[left.lean] - leanRank[right.lean];
if (leanDelta !== 0) {
return leanDelta;
}
return right.score - left.score;
})
.filter(
(candidate, index, items) =>
items.findIndex((entry) => entry.text === candidate.text) === index,
)
.slice(0, 4);
const durableImplications = candidateSnippets
.filter((candidate) => candidate.lean === "likely_durable" || candidate.score >= 4)
.filter(
(candidate, index, items) =>
items.findIndex((entry) => entry.text === candidate.text) === index,
)
.toSorted((left, right) => right.score - left.score)
.slice(0, REM_SUMMARY_MEMORY_LIMIT)
.map((candidate) => ({ text: candidate.text, refs: candidate.refs }));
const candidateDrivenImplications = candidateSnippets
.filter((candidate) => candidate.lean !== "likely_situational" && candidate.score >= 2.2)
.filter(
(candidate, index, items) =>
items.findIndex((entry) => entry.text === candidate.text) === index,
)
.toSorted((left, right) => right.score - left.score)
.slice(0, REM_SUMMARY_MEMORY_LIMIT)
.map((candidate) => ({ text: candidate.text, refs: candidate.refs }));
const effectiveMemoryImplications =
durableImplications.length > 0
? durableImplications
: candidateDrivenImplications.length > 0
? candidateDrivenImplications
: memoryImplications;
const facts: GroundedRemPreviewItem[] = [];
const usedFactTexts = new Set<string>();
for (const summary of factSummaries.toSorted((left, right) => right.score - left.score)) {
const key = summary.text.toLowerCase();
if (usedFactTexts.has(key)) {
continue;
}
usedFactTexts.add(key);
facts.push({ text: summary.text, refs: summary.refs });
if (facts.length >= REM_SUMMARY_FACT_LIMIT) {
break;
}
}
if (facts.length === 0) {
const bestFor = (metric: keyof SectionSummary["scores"]) =>
summaries
.filter((summary) => summary.scores[metric] > 0)
.toSorted((left, right) => {
if (right.scores[metric] !== left.scores[metric]) {
return right.scores[metric] - left.scores[metric];
}
return right.scores.overall - left.scores.overall;
})[0];
for (const summary of [
bestFor("preference"),
bestFor("routing"),
bestFor("externalization"),
...summaries.toSorted((left, right) => right.scores.overall - left.scores.overall),
]) {
if (!summary) {
continue;
}
const key = summary.text.toLowerCase();
if (usedFactTexts.has(key)) {
continue;
}
usedFactTexts.add(key);
facts.push({ text: summary.text, refs: summary.refs });
if (facts.length >= REM_SUMMARY_FACT_LIMIT) {
break;
}
}
}
const reflections: GroundedRemPreviewItem[] = [];
const seenReflections = new Set<string>();
const buildSignal = summaries.reduce((sum, item) => sum + item.scores.build, 0);
const incidentSignal = summaries.reduce((sum, item) => sum + item.scores.incident, 0);
const logisticsSignal = summaries.reduce((sum, item) => sum + item.scores.logistics, 0);
const routingSignal = summaries.reduce((sum, item) => sum + item.scores.routing, 0);
const externalizationSignal = summaries.reduce(
(sum, item) => sum + item.scores.externalization,
0,
);
const retrySignal = summaries.reduce((sum, item) => sum + item.scores.retries, 0);
const taskSignal = sectionScores.reduce(
(sum, { section, snippets }) => sum + scoreSection(section, snippets).tasks,
0,
);
const strongestRoutingSummary = summaries
.filter((summary) => isRoutingSummary(summary))
.toSorted((left, right) => right.scores.overall - left.scores.overall)[0];
const strongestIncidentSummary = summaries
.filter((summary) => summary.scores.incident > 0)
.toSorted((left, right) => right.scores.overall - left.scores.overall)[0];
const strongestExternalizationSummary = summaries
.filter((summary) => summary.scores.externalization > 0)
.toSorted((left, right) => right.scores.overall - left.scores.overall)[0];
if (effectiveMemoryImplications.length > 0) {
addReflection(
reflections,
seenReflections,
"A stable rule or preference was stated explicitly, which suggests operating choices are being made legible instead of left implicit.",
effectiveMemoryImplications.flatMap((item) => item.refs).slice(0, 3),
);
}
if (facts.length > 0 && routingSignal >= 2 && strongestRoutingSummary && buildSignal >= incidentSignal) {
addReflection(
reflections,
seenReflections,
"The strongest pattern here is a preference for converting messy inbound information into routed workflows with different downstream actions, instead of handling each case manually.",
strongestRoutingSummary.refs,
);
}
if (facts.length > 0 && externalizationSignal >= 2 && strongestExternalizationSummary) {
addReflection(
reflections,
seenReflections,
"Important context tends to get externalized quickly into notes, trackers, or memory surfaces, which suggests a preference for explicit systems over holding context informally.",
strongestExternalizationSummary.refs,
);
}
if (facts.length > 0 && buildSignal >= 2) {
const buildRefs = facts
.filter((item) => REM_BUILD_SIGNAL_RE.test(item.text))
.flatMap((item) => item.refs)
.slice(0, 3);
if (buildRefs.length > 0) {
addReflection(
reflections,
seenReflections,
"The day leaned toward building operator infrastructure, which suggests the interaction is often used to reshape the system around recurring needs rather than just complete isolated tasks.",
buildRefs,
);
}
}
if (facts.length > 0 && incidentSignal >= 2 && strongestIncidentSummary) {
addReflection(
reflections,
seenReflections,
retrySignal >= 2
? "When something breaks repeatedly, the response is systematic: retries, root-cause narrowing, and preserving enough state to resume once the blocker is fixed."
: "A meaningful share of the day went into friction, and the interaction pattern looks pragmatic rather than emotional: diagnose the blocker, preserve state, and move on.",
strongestIncidentSummary.refs,
);
}
if (facts.length > 0 && logisticsSignal >= 2) {
const logisticsRefs = facts
.filter((item) => REM_LOGISTICS_SIGNAL_RE.test(item.text))
.flatMap((item) => item.refs)
.slice(0, 3);
if (logisticsRefs.length > 0) {
addReflection(
reflections,
seenReflections,
"Personal logistics and operating-system work are being managed in the same surface, which suggests a preference for one integrated control plane rather than separate personal and technical loops.",
logisticsRefs,
);
}
}
if (taskSignal >= 3 && reflections.length === 0) {
addReflection(
reflections,
seenReflections,
"The raw note is mostly task and current-state material, so it should not be over-read as memory.",
[
makeRef(
params.relPath,
sections[0]?.startLine ?? 1,
sections[sections.length - 1]?.endLine ?? 1,
),
],
);
}
const visibleReflections = reflections.slice(0, REM_SUMMARY_REFLECTION_LIMIT);
const renderedLines: string[] = [];
renderedLines.push("## What Happened");
if (facts.length === 0) {
renderedLines.push("1. No grounded facts were extracted.");
} else {
for (const [index, fact] of facts.entries()) {
renderedLines.push(`${index + 1}. ${fact.text} [${fact.refs.join(", ")}]`);
}
}
renderedLines.push("");
renderedLines.push("## Reflections");
if (visibleReflections.length === 0) {
renderedLines.push("1. No grounded reflections emerged from this note yet.");
} else {
for (const [index, reflection] of visibleReflections.entries()) {
renderedLines.push(`${index + 1}. ${reflection.text} [${reflection.refs.join(", ")}]`);
}
}
if (candidates.length > 0) {
renderedLines.push("");
renderedLines.push("## Candidates");
for (const candidate of candidates) {
renderedLines.push(`- [${candidate.lean}] ${candidate.text} [${candidate.refs.join(", ")}]`);
}
}
if (effectiveMemoryImplications.length > 0) {
renderedLines.push("");
renderedLines.push("## Possible Lasting Updates");
for (const implication of effectiveMemoryImplications) {
renderedLines.push(`- ${implication.text} [${implication.refs.join(", ")}]`);
}
}
return {
path: params.relPath,
facts,
reflections: visibleReflections,
memoryImplications: effectiveMemoryImplications,
candidates,
renderedMarkdown: renderedLines.join("\n"),
};
}
async function collectMarkdownFiles(inputPaths: string[]): Promise<string[]> {
const found = new Set<string>();
async function walk(targetPath: string): Promise<void> {
const resolved = path.resolve(targetPath);
const stat = await fs.stat(resolved);
if (stat.isDirectory()) {
const entries = await fs.readdir(resolved, { withFileTypes: true });
for (const entry of entries) {
await walk(path.join(resolved, entry.name));
}
return;
}
if (stat.isFile() && resolved.toLowerCase().endsWith(".md")) {
found.add(resolved);
}
}
for (const inputPath of inputPaths) {
const trimmed = inputPath.trim();
if (!trimmed) {
continue;
}
await walk(trimmed);
}
return Array.from(found).toSorted((left, right) => left.localeCompare(right));
}
export async function previewGroundedRemMarkdown(params: {
workspaceDir: string;
inputPaths: string[];
}): Promise<GroundedRemPreviewResult> {
const workspaceDir = params.workspaceDir.trim();
const files = await collectMarkdownFiles(params.inputPaths);
const previews: GroundedRemFilePreview[] = [];
for (const filePath of files) {
const content = await fs.readFile(filePath, "utf-8");
const relPath = normalizePath(path.relative(workspaceDir, filePath));
previews.push(previewGroundedRemForFile({ relPath, content }));
}
return {
workspaceDir,
scannedFiles: files.length,
files: previews,
};
}