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); const LONG_TASK_WRAPPER_TIMEOUT_MS = 8000; const LONG_TASK_GATE_LOCK_TIMEOUT_MS = 8000; type GateLockResult = { gateRequired: boolean; gateStatus: "not_applicable" | "pass" | "fail"; reasons?: string[]; requiredEvidence?: Array<{ evidenceKey?: string; acceptedFields?: string[]; requiredValue?: string; }>; allowedResponseModes?: string[]; }; 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 runJsonScript(scriptPath: string, workspaceDir: string, input: Record, timeout: number): Promise { let tempInputPath: string | null = null; try { tempInputPath = path.join( os.tmpdir(), `openclaw-hook-${path.basename(scriptPath, path.extname(scriptPath))}-${process.pid}-${Date.now()}.json`, ); await fs.writeFile(tempInputPath, JSON.stringify(input), "utf-8"); const { stdout } = await execFileAsync("node", [scriptPath, "--compact", "--input", tempInputPath], { cwd: workspaceDir, maxBuffer: 1024 * 1024, timeout, }); return JSON.parse(stdout); } catch { return null; } finally { if (tempInputPath) { await fs.unlink(tempInputPath).catch(() => {}); } } } async function runLongTaskWrapper(workspaceDir: string, ctx: any): Promise { 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: "", }; return runJsonScript(wrapperPath, workspaceDir, input, LONG_TASK_WRAPPER_TIMEOUT_MS); } function buildProgressEvidence(wrapperResult: any): Record | null { const progressEvidence: Record = {}; const taskName = typeof wrapperResult?.taskRecord?.task_name === "string" ? wrapperResult.taskRecord.task_name.trim() : ""; if (wrapperResult?.silentLaunchOk === true && taskName) { progressEvidence.sessionKey = taskName; } return Object.keys(progressEvidence).length > 0 ? progressEvidence : null; } function buildGateLockInput(wrapperResult: any): Record { if (!wrapperResult || wrapperResult.classification !== "long_task") { return { classification: wrapperResult?.classification ?? "general_chat" }; } const needsOwnerDecision = wrapperResult.needsOwnerDecision === true; const silentCandidate = wrapperResult.silentCandidate === true; const progressEvidence = buildProgressEvidence(wrapperResult); const requiredNextAction = typeof wrapperResult.requiredNextAction === "string" ? wrapperResult.requiredNextAction.trim() : ""; const hasConcreteExecutionEvidence = Boolean( requiredNextAction && ![ "", "proceed_with_normal_long_task_flow", "proceed_with_silent_launch", "define_first_checkpoint_trigger_before_silent_launch", "bind_externalized_checkpoint_path_or_abort_silent_launch", ].includes(requiredNextAction), ); const executionEvidence = hasConcreteExecutionEvidence ? { concreteNextAction: requiredNextAction, } : null; const hasExternalizedCheckpointEvidence = wrapperResult.silentLaunchOk === true && typeof wrapperResult.taskRecord?.task_name === "string" && wrapperResult.taskRecord.task_name.trim().length > 0; const hasButtonPathClosureEvidence = needsOwnerDecision && wrapperResult.silentLaunchOk === true; const claimedProgression = wrapperResult.classification === "long_task" ? "already progressing to the next step in background" : ""; return { classification: wrapperResult.classification, silentContinuation: silentCandidate, claimedExecution: true, needsOwnerDecision, nextStep: hasConcreteExecutionEvidence ? requiredNextAction : "", requiredNextAction: hasConcreteExecutionEvidence ? requiredNextAction : "", concreteNextAction: hasConcreteExecutionEvidence ? requiredNextAction : "", progressionClaim: claimedProgression, claimedProgression: claimedProgression, statusSummary: claimedProgression, executionEvidence, progressEvidence, sessionKey: typeof progressEvidence?.sessionKey === "string" ? progressEvidence.sessionKey : "", runId: typeof progressEvidence?.runId === "string" ? progressEvidence.runId : "", modified_files: Array.isArray(progressEvidence?.modified_files) ? progressEvidence.modified_files : [], verificationResult: typeof progressEvidence?.verificationResult === "string" ? progressEvidence.verificationResult : "", toolCallEvidence: "", dispatchEvidence: "", fileChangeEvidence: "", verificationEvidence: "", checkpointArtifactEvidence: hasExternalizedCheckpointEvidence ? wrapperResult.taskRecord.task_name.trim() : "", externalizedCheckpointPath: hasExternalizedCheckpointEvidence ? wrapperResult.taskRecord.task_name.trim() : "", externalizedTrigger: hasExternalizedCheckpointEvidence ? "hook-preflight-checkpoint" : "", handoffMode: hasButtonPathClosureEvidence ? (wrapperResult.handoff?.mode ?? "button_path") : "direct_reply", replyClosureMode: hasButtonPathClosureEvidence ? (wrapperResult.handoff?.mode ?? "button_path") : "direct_reply", }; } async function runLongTaskGateLock(workspaceDir: string, wrapperResult: any): Promise { const gateLockPath = path.join(workspaceDir, "scripts", "long_task_gate_lock.mjs"); const input = buildGateLockInput(wrapperResult); return runJsonScript(gateLockPath, workspaceDir, input, LONG_TASK_GATE_LOCK_TIMEOUT_MS); } function buildWrapperEnforcement(wrapperResult: any): string[] { const lines = [ "- Treat this as ingress preflight guidance from the wrapper MVP.", ]; if (wrapperResult.classification === "long_task") { lines.push("- ENFORCEMENT: This request defaults to long-task governance; do not treat it as ordinary single-turn chat unless you can clearly justify overriding the classifier."); lines.push("- ENFORCEMENT: If you proceed, prefer explicit task state and checkpoint discipline over ad-hoc continuation."); } if (wrapperResult.handoff?.mode === "button_path") { lines.push("- ENFORCEMENT: Owner decision is expected; plan Telegram button-path early instead of ending with a plain-text menu."); } if (wrapperResult.silentCandidate === true && wrapperResult.silentLaunchOk === false) { lines.push("- ENFORCEMENT: Silent launch is NOT allowed in the current form."); lines.push("- ENFORCEMENT: Use the recommended fallback before proceeding."); if (wrapperResult.requiredNextAction) { lines.push(`- ENFORCEMENT: Required next action = ${wrapperResult.requiredNextAction}`); } } else if (wrapperResult.silentCandidate === true && wrapperResult.silentLaunchOk === true) { lines.push("- ENFORCEMENT: Silent launch is only acceptable if you preserve externalized checkpoint discipline and do not rely on memory alone."); } return lines; } function buildWrapperHardGate(wrapperResult: any): string[] { const lines: string[] = []; if (wrapperResult.classification === "long_task") { lines.push("- HARD_GATE: If you intend to proceed as ordinary chat, you must explicitly justify why long-task governance does not apply."); } if (wrapperResult.handoff?.mode === "button_path") { lines.push("- HARD_GATE: Do not end this flow with a plain-text choice menu. Use Telegram inline buttons or execute the most reasonable next step directly."); } if (wrapperResult.silentCandidate === true && wrapperResult.silentLaunchOk === false) { lines.push("- HARD_GATE: Do NOT launch or continue this task in silent mode in its current form."); lines.push("- HARD_GATE: Before any silent execution, satisfy the required next action or downgrade to non-silent follow-up."); } return lines; } function buildGateLockBlock(gateLockResult: GateLockResult | null): string { if (!gateLockResult) { return [ "[LONG_TASK_GATE_LOCK]", "gateStatus=degraded", "gateRequired=unknown", "- ENFORCEMENT: Gate-lock evaluator unavailable; keep existing long-task safeguards in force.", "- ENFORCEMENT: Do not claim you have progressed into the next task or are already pushing the next step unless you have concrete progress evidence such as a sessionKey, runId, modified_files record, verification result, actual dispatch, tool calls, file changes, or a persisted checkpoint artifact.", "- HARD_GATE: Evaluator unavailable is not permission to claim silent continuation or next-task progression without verifiable progress evidence.", "- HARD_GATE: Fall back to a non-silent, evidence-preserving follow-up if you cannot prove checkpoint state or concrete execution.", "[/LONG_TASK_GATE_LOCK]", "", ].join("\n"); } const lines = [ "[LONG_TASK_GATE_LOCK]", `gateRequired=${gateLockResult.gateRequired}`, `gateStatus=${gateLockResult.gateStatus}`, ...(gateLockResult.reasons ?? []).map((reason) => `reason=${reason}`), ...((gateLockResult.requiredEvidence ?? []).map((requirement) => { const fields = (requirement.acceptedFields ?? []).join(","); return `requiredEvidence=${requirement.evidenceKey ?? "unknown"};fields=${fields};requiredValue=${requirement.requiredValue ?? "unknown"}`; })), ...((gateLockResult.allowedResponseModes ?? []).map((mode) => `allowedResponseMode=${mode}`)), "- ENFORCEMENT: Do not claim you have progressed into the next task or are already pushing the next step unless you have concrete progress evidence such as a sessionKey, runId, modified_files record, verification result, actual dispatch, tool calls, file changes, or a persisted checkpoint artifact.", "- ENFORCEMENT: Forbidden path: plain-text handoff that pretends the long task is already continuing without an externalized checkpoint.", "- ENFORCEMENT: Forbidden path: stating you have already entered the next task/step when the record only contains planning language and no concrete execution evidence.", ]; if (gateLockResult.gateStatus === "fail") { lines.push("- HARD_GATE: Block any plain-text handoff or silent-continuation claim when externalized checkpoint evidence is missing."); lines.push("- HARD_GATE: Block any reply path that says you already moved into the next task or are advancing the next step without concrete progress evidence."); lines.push("- HARD_GATE: Do not say you are already on the next task, already dispatched follow-up work, or already progressing in background unless you can point to a sessionKey, runId, modified_files record, verification result, actual tool execution, file changes, emitted messages, or checkpoint records."); lines.push("- HARD_GATE: If required evidence is missing, ask for/produce the checkpoint or downgrade to a non-silent, evidence-preserving follow-up."); lines.push("- HARD_GATE: If owner decision is involved, do not replace button-path closure with plain-text handoff."); } lines.push("[/LONG_TASK_GATE_LOCK]", ""); return lines.join("\n"); } /** * 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), ]); const gateLockResult = wrapperResult ? await runLongTaskGateLock(workspaceDir, wrapperResult) : null; if (!rulebook && !soul && !wrapperResult && !gateLockResult) 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, ...buildWrapperEnforcement(wrapperResult), ...buildWrapperHardGate(wrapperResult), "[/LONG_TASK_GOVERNOR_PREFLIGHT]", "", ] .filter(Boolean) .join("\n") : ""; const gateLockBlock = buildGateLockBlock(gateLockResult); 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, gateLockBlock, 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;