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 { 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 { 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;