143 lines
5.0 KiB
TypeScript
143 lines
5.0 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
function clamp(s: string, max = 1200): string {
|
|
if (!s) return s;
|
|
if (s.length <= max) return s;
|
|
return s.slice(0, max) + "\n…(truncated)…";
|
|
}
|
|
|
|
async function safeReadText(filePath: string): Promise<string | null> {
|
|
try {
|
|
const raw = await fs.readFile(filePath, "utf-8");
|
|
const trimmed = raw.trim();
|
|
return trimmed ? trimmed : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function runLongTaskWrapper(workspaceDir: string, ctx: any): Promise<any | null> {
|
|
let tempInputPath: string | null = null;
|
|
|
|
try {
|
|
const wrapperPath = path.join(workspaceDir, "scripts", "long_task_governor_wrapper.mjs");
|
|
const input = {
|
|
requestText: (ctx.body ?? ctx.content ?? ctx.bodyForAgent ?? "") as string,
|
|
hasFilesOrSystems: false,
|
|
needsWaiting: false,
|
|
needsSubagent: false,
|
|
needsOwnerDecision: false,
|
|
canReplyNow: false,
|
|
taskName: "Hook preflight classification",
|
|
currentStep: "Classifying request at preprocessed hook",
|
|
nextStep: "Carry governor recommendation into prompt context",
|
|
nextReportCondition: "At next meaningful milestone",
|
|
waitingOn: "none",
|
|
blocker: "none",
|
|
checkpointTrigger: "",
|
|
externalizedTrigger: "",
|
|
triggerKind: "",
|
|
};
|
|
|
|
tempInputPath = path.join(os.tmpdir(), `openclaw-long-task-hook-${process.pid}-${Date.now()}.json`);
|
|
await fs.writeFile(tempInputPath, JSON.stringify(input), "utf-8");
|
|
|
|
const { stdout } = await execFileAsync("node", [wrapperPath, "--compact", "--input", tempInputPath], {
|
|
cwd: workspaceDir,
|
|
maxBuffer: 1024 * 1024,
|
|
timeout: 8000,
|
|
});
|
|
|
|
return JSON.parse(stdout);
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
if (tempInputPath) {
|
|
await fs.unlink(tempInputPath).catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Force Recall hook handler
|
|
*
|
|
* Event: message:preprocessed
|
|
* - Reads docs/RULEBOOK.md and SOUL.md from the resolved workspace
|
|
* - Prepends a recall gate block to context.bodyForAgent
|
|
* - Optionally injects wrapper MVP classification hints when available
|
|
*/
|
|
const forceRecall = async (event: any) => {
|
|
if (event?.type !== "message" || event?.action !== "preprocessed") return;
|
|
|
|
const ctx = event.context ?? {};
|
|
const workspaceDir: string | undefined = ctx.workspaceDir;
|
|
if (!workspaceDir) return;
|
|
|
|
const rulebookPath = path.join(workspaceDir, "docs", "RULEBOOK.md");
|
|
const soulPath = path.join(workspaceDir, "SOUL.md");
|
|
|
|
const [rulebook, soul, wrapperResult] = await Promise.all([
|
|
safeReadText(rulebookPath),
|
|
safeReadText(soulPath),
|
|
runLongTaskWrapper(workspaceDir, ctx),
|
|
]);
|
|
|
|
if (!rulebook && !soul && !wrapperResult) return;
|
|
|
|
const wrapperBlock = wrapperResult
|
|
? [
|
|
"[LONG_TASK_GOVERNOR_PREFLIGHT]",
|
|
`classification=${wrapperResult.classification}`,
|
|
`silentCandidate=${wrapperResult.silentCandidate}`,
|
|
`needsCheckpoint=${wrapperResult.needsCheckpoint}`,
|
|
`needsSubagent=${wrapperResult.needsSubagent}`,
|
|
`needsOwnerDecision=${wrapperResult.needsOwnerDecision}`,
|
|
`silentLaunchOk=${wrapperResult.silentLaunchOk}`,
|
|
wrapperResult.silentLaunchReason ? `silentLaunchReason=${wrapperResult.silentLaunchReason}` : null,
|
|
wrapperResult.recommendedFallback ? `recommendedFallback=${wrapperResult.recommendedFallback}` : null,
|
|
wrapperResult.requiredNextAction ? `requiredNextAction=${wrapperResult.requiredNextAction}` : null,
|
|
wrapperResult.handoff?.mode ? `handoff.mode=${wrapperResult.handoff.mode}` : null,
|
|
"- Treat this as preflight guidance from the wrapper MVP, not final truth.",
|
|
"- If classification=long_task, prefer task state + checkpoint discipline.",
|
|
"- If silentCandidate=true and silentLaunchOk=false, do not launch silent mode as-is.",
|
|
"[/LONG_TASK_GOVERNOR_PREFLIGHT]",
|
|
"",
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n")
|
|
: "";
|
|
|
|
const recallBlock = [
|
|
"[RECALL_GATE] Mandatory recall before ANY technical action/tool use.",
|
|
"- You MUST consult and follow the key rules from RULEBOOK + SOUL.",
|
|
"- If you are about to run tools, change configs, modify code, or delegate agents: restate the applicable rules first.",
|
|
"",
|
|
wrapperBlock || null,
|
|
rulebook ? `RULEBOOK (source: ${rulebookPath}):\n${clamp(rulebook, 1200)}` : null,
|
|
soul ? `SOUL (source: ${soulPath}):\n${clamp(soul, 1200)}` : null,
|
|
"[/RECALL_GATE]",
|
|
"",
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
|
|
const prior = (ctx.bodyForAgent ?? ctx.body ?? ctx.content ?? "") as string;
|
|
const injected = `${recallBlock}${prior ? "\n" + prior : ""}`;
|
|
|
|
ctx.bodyForAgent = injected;
|
|
event.context = ctx;
|
|
|
|
if (process.env.OPENCLAW_FORCE_RECALL_DEBUG === "1") {
|
|
ctx.bodyForAgent += "\n\n[force-recall:debug] injected";
|
|
console.log(`[force-recall:debug] injected for chat=${ctx.chatId ?? "?"} msg=${ctx.messageId ?? "?"}`);
|
|
}
|
|
};
|
|
|
|
export default forceRecall;
|