Files
openclaw/src/auto-reply/reply/post-compaction-context.ts
Efe Büken 03b9abab84 feat(compaction): make post-compaction context sections configurable (#34556)
Merged via squash.

Prepared head SHA: 491bb28544b2e0d3563dd1c78593ed2d829d65f6
Co-authored-by: efe-arv <259833796+efe-arv@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-06 14:57:15 -08:00

234 lines
8.1 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { resolveCronStyleNow } from "../../agents/current-time.js";
import { resolveUserTimezone } from "../../agents/date-time.js";
import type { OpenClawConfig } from "../../config/config.js";
import { openBoundaryFile } from "../../infra/boundary-file-read.js";
const MAX_CONTEXT_CHARS = 3000;
const DEFAULT_POST_COMPACTION_SECTIONS = ["Session Startup", "Red Lines"];
const LEGACY_POST_COMPACTION_SECTIONS = ["Every Session", "Safety"];
// Compare configured section names as a case-insensitive set so deployments can
// pin the documented defaults in any order without changing fallback semantics.
function matchesSectionSet(sectionNames: string[], expectedSections: string[]): boolean {
if (sectionNames.length !== expectedSections.length) {
return false;
}
const counts = new Map<string, number>();
for (const name of expectedSections) {
const normalized = name.trim().toLowerCase();
counts.set(normalized, (counts.get(normalized) ?? 0) + 1);
}
for (const name of sectionNames) {
const normalized = name.trim().toLowerCase();
const count = counts.get(normalized);
if (!count) {
return false;
}
if (count === 1) {
counts.delete(normalized);
} else {
counts.set(normalized, count - 1);
}
}
return counts.size === 0;
}
function formatDateStamp(nowMs: number, timezone: string): string {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date(nowMs));
const year = parts.find((p) => p.type === "year")?.value;
const month = parts.find((p) => p.type === "month")?.value;
const day = parts.find((p) => p.type === "day")?.value;
if (year && month && day) {
return `${year}-${month}-${day}`;
}
return new Date(nowMs).toISOString().slice(0, 10);
}
/**
* Read critical sections from workspace AGENTS.md for post-compaction injection.
* Returns formatted system event text, or null if no AGENTS.md or no relevant sections.
* Substitutes YYYY-MM-DD placeholders with the real date so agents read the correct
* daily memory files instead of guessing based on training cutoff.
*/
export async function readPostCompactionContext(
workspaceDir: string,
cfg?: OpenClawConfig,
nowMs?: number,
): Promise<string | null> {
const agentsPath = path.join(workspaceDir, "AGENTS.md");
try {
const opened = await openBoundaryFile({
absolutePath: agentsPath,
rootPath: workspaceDir,
boundaryLabel: "workspace root",
});
if (!opened.ok) {
return null;
}
const content = (() => {
try {
return fs.readFileSync(opened.fd, "utf-8");
} finally {
fs.closeSync(opened.fd);
}
})();
// Extract configured sections from AGENTS.md (default: Session Startup + Red Lines).
// An explicit empty array disables post-compaction context injection entirely.
const configuredSections = cfg?.agents?.defaults?.compaction?.postCompactionSections;
const sectionNames = Array.isArray(configuredSections)
? configuredSections
: DEFAULT_POST_COMPACTION_SECTIONS;
if (sectionNames.length === 0) {
return null;
}
const foundSectionNames: string[] = [];
let sections = extractSections(content, sectionNames, foundSectionNames);
// Fall back to legacy section names ("Every Session" / "Safety") when using
// defaults and the current headings aren't found — preserves compatibility
// with older AGENTS.md templates. The fallback also applies when the user
// explicitly configures the default pair, so that pinning the documented
// defaults never silently changes behavior vs. leaving the field unset.
const isDefaultSections =
!Array.isArray(configuredSections) ||
matchesSectionSet(configuredSections, DEFAULT_POST_COMPACTION_SECTIONS);
if (sections.length === 0 && isDefaultSections) {
sections = extractSections(content, LEGACY_POST_COMPACTION_SECTIONS, foundSectionNames);
}
if (sections.length === 0) {
return null;
}
// Only reference section names that were actually found and injected.
const displayNames = foundSectionNames.length > 0 ? foundSectionNames : sectionNames;
const resolvedNowMs = nowMs ?? Date.now();
const timezone = resolveUserTimezone(cfg?.agents?.defaults?.userTimezone);
const dateStamp = formatDateStamp(resolvedNowMs, timezone);
// Always append the real runtime timestamp — AGENTS.md content may itself contain
// "Current time:" as user-authored text, so we must not gate on that substring.
const { timeLine } = resolveCronStyleNow(cfg ?? {}, resolvedNowMs);
const combined = sections.join("\n\n").replaceAll("YYYY-MM-DD", dateStamp);
const safeContent =
combined.length > MAX_CONTEXT_CHARS
? combined.slice(0, MAX_CONTEXT_CHARS) + "\n...[truncated]..."
: combined;
// When using the default section set, use precise prose that names the
// "Session Startup" sequence explicitly. When custom sections are configured,
// use generic prose — referencing a hardcoded "Session Startup" sequence
// would be misleading for deployments that use different section names.
const prose = isDefaultSections
? "Session was just compacted. The conversation summary above is a hint, NOT a substitute for your startup sequence. " +
"Execute your Session Startup sequence now — read the required files before responding to the user."
: `Session was just compacted. The conversation summary above is a hint, NOT a substitute for your full startup sequence. ` +
`Re-read the sections injected below (${displayNames.join(", ")}) and follow your configured startup procedure before responding to the user.`;
const sectionLabel = isDefaultSections
? "Critical rules from AGENTS.md:"
: `Injected sections from AGENTS.md (${displayNames.join(", ")}):`;
return (
"[Post-compaction context refresh]\n\n" +
`${prose}\n\n` +
`${sectionLabel}\n\n${safeContent}\n\n${timeLine}`
);
} catch {
return null;
}
}
/**
* Extract named sections from markdown content.
* Matches H2 (##) or H3 (###) headings case-insensitively.
* Skips content inside fenced code blocks.
* Captures until the next heading of same or higher level, or end of string.
*/
export function extractSections(
content: string,
sectionNames: string[],
foundNames?: string[],
): string[] {
const results: string[] = [];
const lines = content.split("\n");
for (const name of sectionNames) {
let sectionLines: string[] = [];
let inSection = false;
let sectionLevel = 0;
let inCodeBlock = false;
for (const line of lines) {
// Track fenced code blocks
if (line.trimStart().startsWith("```")) {
inCodeBlock = !inCodeBlock;
if (inSection) {
sectionLines.push(line);
}
continue;
}
// Skip heading detection inside code blocks
if (inCodeBlock) {
if (inSection) {
sectionLines.push(line);
}
continue;
}
// Check if this line is a heading
const headingMatch = line.match(/^(#{2,3})\s+(.+?)\s*$/);
if (headingMatch) {
const level = headingMatch[1].length; // 2 or 3
const headingText = headingMatch[2];
if (!inSection) {
// Check if this is our target section (case-insensitive)
if (headingText.toLowerCase() === name.toLowerCase()) {
inSection = true;
sectionLevel = level;
sectionLines = [line];
continue;
}
} else {
// We're in section — stop if we hit a heading of same or higher level
if (level <= sectionLevel) {
break;
}
// Lower-level heading (e.g., ### inside ##) — include it
sectionLines.push(line);
continue;
}
}
if (inSection) {
sectionLines.push(line);
}
}
if (sectionLines.length > 0) {
results.push(sectionLines.join("\n").trim());
foundNames?.push(name);
}
}
return results;
}