diff --git a/docs/cli/hooks.md b/docs/cli/hooks.md index 49d76cd5a..737e6189b 100644 --- a/docs/cli/hooks.md +++ b/docs/cli/hooks.md @@ -256,3 +256,15 @@ grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq . ``` **See:** [command-logger documentation](/hooks#command-logger) + +### soul-evil + +Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance. + +**Enable:** + +```bash +clawdbot hooks enable soul-evil +``` + +**See:** [soul-evil documentation](/hooks#soul-evil) diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md index 878e11fa4..a56ca611f 100644 --- a/docs/concepts/system-prompt.md +++ b/docs/concepts/system-prompt.md @@ -58,6 +58,9 @@ Large files are truncated with a marker. The max per-file size is controlled by `agents.defaults.bootstrapMaxChars` (default: 20000). Missing files inject a short missing-file marker. +Internal hooks can intercept this step via `agent:bootstrap` to mutate or replace +the injected bootstrap files (for example swapping `SOUL.md` for an alternate persona). + To inspect how much each injected file contributes (raw vs injected, truncation, plus tool schema overhead), use `/context list` or `/context detail`. See [Context](/concepts/context). ## Time handling diff --git a/docs/hooks.md b/docs/hooks.md index ef35d9aa4..b1dc11a32 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -203,6 +203,8 @@ Each event includes: sessionFile?: string, commandSource?: string, // e.g., 'whatsapp', 'telegram' senderId?: string, + workspaceDir?: string, + bootstrapFiles?: WorkspaceBootstrapFile[], cfg?: ClawdbotConfig } } @@ -219,6 +221,10 @@ Triggered when agent commands are issued: - **`command:reset`**: When `/reset` command is issued - **`command:stop`**: When `/stop` command is issued +### Agent Events + +- **`agent:bootstrap`**: Before workspace bootstrap files are injected (hooks may mutate `context.bootstrapFiles`) + ### Future Events Planned event types: @@ -497,6 +503,40 @@ grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq . clawdbot hooks enable command-logger ``` +### soul-evil + +Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance. + +**Events**: `agent:bootstrap` + +**Output**: No files written; swaps happen in-memory only. + +**Enable**: + +```bash +clawdbot hooks enable soul-evil +``` + +**Config**: + +```json +{ + "hooks": { + "internal": { + "enabled": true, + "entries": { + "soul-evil": { + "enabled": true, + "file": "SOUL_EVIL.md", + "chance": 0.1, + "purge": { "at": "21:00", "duration": "15m" } + } + } + } + } +} +``` + ## Best Practices ### Keep Handlers Fast diff --git a/src/agents/bootstrap-hooks.test.ts b/src/agents/bootstrap-hooks.test.ts new file mode 100644 index 000000000..dc6b54bfc --- /dev/null +++ b/src/agents/bootstrap-hooks.test.ts @@ -0,0 +1,41 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { applyBootstrapHookOverrides } from "./bootstrap-hooks.js"; +import { + clearInternalHooks, + registerInternalHook, + type AgentBootstrapHookContext, +} from "../hooks/internal-hooks.js"; +import { DEFAULT_SOUL_FILENAME, type WorkspaceBootstrapFile } from "./workspace.js"; + +function makeFile(name = DEFAULT_SOUL_FILENAME): WorkspaceBootstrapFile { + return { + name, + path: `/tmp/${name}`, + content: "base", + missing: false, + }; +} + +describe("applyBootstrapHookOverrides", () => { + beforeEach(() => clearInternalHooks()); + afterEach(() => clearInternalHooks()); + + it("returns updated files when a hook mutates the context", async () => { + registerInternalHook("agent:bootstrap", (event) => { + const context = event.context as AgentBootstrapHookContext; + context.bootstrapFiles = [ + ...context.bootstrapFiles, + { name: "EXTRA.md", path: "/tmp/EXTRA.md", content: "extra", missing: false }, + ]; + }); + + const updated = await applyBootstrapHookOverrides({ + files: [makeFile()], + workspaceDir: "/tmp", + }); + + expect(updated).toHaveLength(2); + expect(updated[1]?.name).toBe("EXTRA.md"); + }); +}); diff --git a/src/agents/bootstrap-hooks.ts b/src/agents/bootstrap-hooks.ts new file mode 100644 index 000000000..151751487 --- /dev/null +++ b/src/agents/bootstrap-hooks.ts @@ -0,0 +1,31 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import { createInternalHookEvent, triggerInternalHook } from "../hooks/internal-hooks.js"; +import type { AgentBootstrapHookContext } from "../hooks/internal-hooks.js"; +import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; +import type { WorkspaceBootstrapFile } from "./workspace.js"; + +export async function applyBootstrapHookOverrides(params: { + files: WorkspaceBootstrapFile[]; + workspaceDir: string; + config?: ClawdbotConfig; + sessionKey?: string; + sessionId?: string; + agentId?: string; +}): Promise { + const sessionKey = params.sessionKey ?? params.sessionId ?? "unknown"; + const agentId = + params.agentId ?? + (params.sessionKey ? resolveAgentIdFromSessionKey(params.sessionKey) : undefined); + const context: AgentBootstrapHookContext = { + workspaceDir: params.workspaceDir, + bootstrapFiles: params.files, + cfg: params.config, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId, + }; + const event = createInternalHookEvent("agent", "bootstrap", sessionKey, context); + await triggerInternalHook(event); + const updated = (event.context as AgentBootstrapHookContext).bootstrapFiles; + return Array.isArray(updated) ? updated : params.files; +} diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 8902aa573..92d1ee24e 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -7,6 +7,7 @@ import { createSubsystemLogger } from "../logging.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { resolveUserPath } from "../utils.js"; import { resolveSessionAgentIds } from "./agent-scope.js"; +import { applyBootstrapHookOverrides } from "./bootstrap-hooks.js"; import { resolveCliBackendConfig } from "./cli-backends.js"; import { appendImagePathsToPrompt, @@ -76,8 +77,15 @@ export async function runCliAgent(params: { await loadWorkspaceBootstrapFiles(workspaceDir), params.sessionKey ?? params.sessionId, ); + const hookAdjustedBootstrapFiles = await applyBootstrapHookOverrides({ + files: bootstrapFiles, + workspaceDir, + config: params.config, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + }); const sessionLabel = params.sessionKey ?? params.sessionId; - const contextFiles = buildBootstrapContextFiles(bootstrapFiles, { + const contextFiles = buildBootstrapContextFiles(hookAdjustedBootstrapFiles, { maxChars: resolveBootstrapMaxChars(params.config), warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`), }); diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 7c40517c6..a6f0beeb2 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -16,6 +16,7 @@ import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { resolveUserPath } from "../../utils.js"; import { resolveClawdbotAgentDir } from "../agent-paths.js"; import { resolveSessionAgentIds } from "../agent-scope.js"; +import { applyBootstrapHookOverrides } from "../bootstrap-hooks.js"; import type { ExecElevatedDefaults } from "../bash-tools.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; import { getApiKeyForModel, resolveModelAuthMode } from "../model-auth.js"; @@ -182,11 +183,21 @@ export async function compactEmbeddedPiSession(params: { await loadWorkspaceBootstrapFiles(effectiveWorkspace), params.sessionKey ?? params.sessionId, ); - const sessionLabel = params.sessionKey ?? params.sessionId; - const contextFiles: EmbeddedContextFile[] = buildBootstrapContextFiles(bootstrapFiles, { - maxChars: resolveBootstrapMaxChars(params.config), - warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`), + const hookAdjustedBootstrapFiles = await applyBootstrapHookOverrides({ + files: bootstrapFiles, + workspaceDir: effectiveWorkspace, + config: params.config, + sessionKey: params.sessionKey, + sessionId: params.sessionId, }); + const sessionLabel = params.sessionKey ?? params.sessionId; + const contextFiles: EmbeddedContextFile[] = buildBootstrapContextFiles( + hookAdjustedBootstrapFiles, + { + maxChars: resolveBootstrapMaxChars(params.config), + warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`), + }, + ); const runAbortController = new AbortController(); const toolsRaw = createClawdbotCodingTools({ exec: { diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 909b3e2a5..23deead2d 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -17,6 +17,7 @@ import { isSubagentSessionKey } from "../../../routing/session-key.js"; import { resolveUserPath } from "../../../utils.js"; import { resolveClawdbotAgentDir } from "../../agent-paths.js"; import { resolveSessionAgentIds } from "../../agent-scope.js"; +import { applyBootstrapHookOverrides } from "../../bootstrap-hooks.js"; import { resolveModelAuthMode } from "../../model-auth.js"; import { buildBootstrapContextFiles, @@ -124,8 +125,15 @@ export async function runEmbeddedAttempt( await loadWorkspaceBootstrapFiles(effectiveWorkspace), params.sessionKey ?? params.sessionId, ); + const hookAdjustedBootstrapFiles = await applyBootstrapHookOverrides({ + files: bootstrapFiles, + workspaceDir: effectiveWorkspace, + config: params.config, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + }); const sessionLabel = params.sessionKey ?? params.sessionId; - const contextFiles = buildBootstrapContextFiles(bootstrapFiles, { + const contextFiles = buildBootstrapContextFiles(hookAdjustedBootstrapFiles, { maxChars: resolveBootstrapMaxChars(params.config), warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`), }); @@ -251,7 +259,7 @@ export async function runEmbeddedAttempt( return { mode: runtime.mode, sandboxed: runtime.sandboxed }; })(), systemPrompt: appendPrompt, - bootstrapFiles, + bootstrapFiles: hookAdjustedBootstrapFiles, injectedFiles: contextFiles, skillsPrompt, tools, diff --git a/src/auto-reply/reply/commands-context-report.ts b/src/auto-reply/reply/commands-context-report.ts index d8555d13c..7f218756d 100644 --- a/src/auto-reply/reply/commands-context-report.ts +++ b/src/auto-reply/reply/commands-context-report.ts @@ -9,6 +9,7 @@ import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js"; import { buildAgentSystemPrompt } from "../../agents/system-prompt.js"; import { buildSystemPromptReport } from "../../agents/system-prompt-report.js"; import { buildToolSummaryMap } from "../../agents/tool-summaries.js"; +import { applyBootstrapHookOverrides } from "../../agents/bootstrap-hooks.js"; import { filterBootstrapFilesForSession, loadWorkspaceBootstrapFiles, @@ -59,7 +60,14 @@ async function resolveContextReport( await loadWorkspaceBootstrapFiles(workspaceDir), params.sessionKey, ); - const injectedFiles = buildBootstrapContextFiles(bootstrapFiles, { + const hookAdjustedBootstrapFiles = await applyBootstrapHookOverrides({ + files: bootstrapFiles, + workspaceDir, + config: params.cfg, + sessionKey: params.sessionKey, + sessionId: params.sessionEntry?.sessionId, + }); + const injectedFiles = buildBootstrapContextFiles(hookAdjustedBootstrapFiles, { maxChars: bootstrapMaxChars, }); const skillsSnapshot = (() => { @@ -143,7 +151,7 @@ async function resolveContextReport( bootstrapMaxChars, sandbox: { mode: sandboxRuntime.mode, sandboxed: sandboxRuntime.sandboxed }, systemPrompt, - bootstrapFiles, + bootstrapFiles: hookAdjustedBootstrapFiles, injectedFiles, skillsPrompt, tools, diff --git a/src/hooks/bundled/README.md b/src/hooks/bundled/README.md index b2a426332..be1d64ab1 100644 --- a/src/hooks/bundled/README.md +++ b/src/hooks/bundled/README.md @@ -32,6 +32,20 @@ Logs all command events to a centralized audit file. clawdbot hooks enable command-logger ``` +### 😈 soul-evil + +Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance. + +**Events**: `agent:bootstrap` +**What it does**: Overrides the injected SOUL content before the system prompt is built. +**Output**: No files written; swaps happen in-memory only. + +**Enable**: + +```bash +clawdbot hooks enable soul-evil +``` + ## Hook Structure Each hook is a directory containing: @@ -140,6 +154,7 @@ Currently supported events: - **command:new**: `/new` command specifically - **command:reset**: `/reset` command - **command:stop**: `/stop` command +- **agent:bootstrap**: Before workspace bootstrap files are injected More event types coming soon (session lifecycle, agent errors, etc.). diff --git a/src/hooks/bundled/soul-evil/HOOK.md b/src/hooks/bundled/soul-evil/HOOK.md new file mode 100644 index 000000000..3e807594b --- /dev/null +++ b/src/hooks/bundled/soul-evil/HOOK.md @@ -0,0 +1,72 @@ +--- +name: soul-evil +description: "Swap SOUL.md with SOUL_EVIL.md during a purge window or by random chance" +homepage: https://docs.clawd.bot/hooks#soul-evil +metadata: + { + "clawdbot": + { + "emoji": "😈", + "events": ["agent:bootstrap"], + "requires": + { "config": ["hooks.internal.entries.soul-evil.enabled"] }, + "install": [{ "id": "bundled", "kind": "bundled", "label": "Bundled with Clawdbot" }], + }, + } +--- + +# SOUL Evil Hook + +Replaces the injected `SOUL.md` content with `SOUL_EVIL.md` during a daily purge window or by random chance. + +## What It Does + +When enabled and the trigger conditions match, the hook swaps the **injected** `SOUL.md` content before the system prompt is built. It does **not** modify files on disk. + +## Files + +- `SOUL.md` — normal persona (always read) +- `SOUL_EVIL.md` — alternate persona (read only when triggered) + +You can change the filename via hook config. + +## Configuration + +Add this to your config (`~/.clawdbot/clawdbot.json`): + +```json +{ + "hooks": { + "internal": { + "enabled": true, + "entries": { + "soul-evil": { + "enabled": true, + "file": "SOUL_EVIL.md", + "chance": 0.1, + "purge": { "at": "21:00", "duration": "15m" } + } + } + } + } +} +``` + +### Options + +- `file` (string): alternate SOUL filename (default: `SOUL_EVIL.md`) +- `chance` (number 0–1): random chance per run to swap in SOUL_EVIL +- `purge.at` (HH:mm): daily purge window start time (24h) +- `purge.duration` (duration): window length (e.g. `30s`, `10m`, `1h`) + +**Precedence:** purge window wins over chance. + +## Requirements + +- `hooks.internal.entries.soul-evil.enabled` must be set to `true` + +## Enable + +```bash +clawdbot hooks enable soul-evil +``` diff --git a/src/hooks/bundled/soul-evil/handler.ts b/src/hooks/bundled/soul-evil/handler.ts new file mode 100644 index 000000000..383c7294d --- /dev/null +++ b/src/hooks/bundled/soul-evil/handler.ts @@ -0,0 +1,61 @@ +import type { ClawdbotConfig } from "../../../config/config.js"; +import { isSubagentSessionKey } from "../../../routing/session-key.js"; +import { resolveHookConfig } from "../../config.js"; +import type { AgentBootstrapHookContext, HookHandler } from "../../hooks.js"; +import { + applySoulEvilOverride, + type SoulEvilConfig, +} from "../../soul-evil.js"; + +const HOOK_KEY = "soul-evil"; + +function resolveSoulEvilConfig(entry: Record | undefined): SoulEvilConfig | null { + if (!entry) return null; + const file = typeof entry.file === "string" ? entry.file : undefined; + const chance = typeof entry.chance === "number" ? entry.chance : undefined; + const purge = + entry.purge && typeof entry.purge === "object" + ? { + at: typeof (entry.purge as { at?: unknown }).at === "string" + ? (entry.purge as { at?: string }).at + : undefined, + duration: + typeof (entry.purge as { duration?: unknown }).duration === "string" + ? (entry.purge as { duration?: string }).duration + : undefined, + } + : undefined; + if (!file && chance === undefined && !purge) return null; + return { file, chance, purge }; +} + +const soulEvilHook: HookHandler = async (event) => { + if (event.type !== "agent" || event.action !== "bootstrap") return; + + const context = event.context as AgentBootstrapHookContext; + if (context.sessionKey && isSubagentSessionKey(context.sessionKey)) return; + const cfg = context.cfg as ClawdbotConfig | undefined; + const hookConfig = resolveHookConfig(cfg, HOOK_KEY); + if (!hookConfig || hookConfig.enabled === false) return; + + const soulConfig = resolveSoulEvilConfig(hookConfig as Record); + if (!soulConfig) return; + + const workspaceDir = context.workspaceDir; + if (!workspaceDir || !Array.isArray(context.bootstrapFiles)) return; + + const updated = await applySoulEvilOverride({ + files: context.bootstrapFiles, + workspaceDir, + config: soulConfig, + userTimezone: cfg?.agents?.defaults?.userTimezone, + log: { + warn: (message) => console.warn(`[soul-evil] ${message}`), + debug: (message) => console.debug?.(`[soul-evil] ${message}`), + }, + }); + + context.bootstrapFiles = updated; +}; + +export default soulEvilHook; diff --git a/src/hooks/internal-hooks.ts b/src/hooks/internal-hooks.ts index 01ef592a6..adb652d88 100644 --- a/src/hooks/internal-hooks.ts +++ b/src/hooks/internal-hooks.ts @@ -5,8 +5,20 @@ * like command processing, session lifecycle, etc. */ +import type { WorkspaceBootstrapFile } from "../agents/workspace.js"; +import type { ClawdbotConfig } from "../config/config.js"; + export type InternalHookEventType = "command" | "session" | "agent"; +export type AgentBootstrapHookContext = { + workspaceDir: string; + bootstrapFiles: WorkspaceBootstrapFile[]; + cfg?: ClawdbotConfig; + sessionKey?: string; + sessionId?: string; + agentId?: string; +}; + export interface InternalHookEvent { /** The type of event (command, session, agent, etc.) */ type: InternalHookEventType; diff --git a/src/hooks/soul-evil.test.ts b/src/hooks/soul-evil.test.ts new file mode 100644 index 000000000..90cb68ffd --- /dev/null +++ b/src/hooks/soul-evil.test.ts @@ -0,0 +1,138 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + applySoulEvilOverride, + decideSoulEvil, + DEFAULT_SOUL_EVIL_FILENAME, +} from "./soul-evil.js"; +import { + DEFAULT_SOUL_FILENAME, + type WorkspaceBootstrapFile, +} from "../agents/workspace.js"; + +const makeFiles = (overrides?: Partial) => [ + { + name: DEFAULT_SOUL_FILENAME, + path: "/tmp/SOUL.md", + content: "friendly", + missing: false, + ...overrides, + }, +]; + +describe("decideSoulEvil", () => { + it("returns false when no config", () => { + const result = decideSoulEvil({}); + expect(result.useEvil).toBe(false); + }); + + it("activates on random chance", () => { + const result = decideSoulEvil({ + config: { chance: 0.5 }, + random: () => 0.2, + }); + expect(result.useEvil).toBe(true); + expect(result.reason).toBe("chance"); + }); + + it("activates during purge window", () => { + const result = decideSoulEvil({ + config: { + purge: { at: "00:00", duration: "10m" }, + }, + userTimezone: "UTC", + now: new Date("2026-01-01T00:05:00Z"), + }); + expect(result.useEvil).toBe(true); + expect(result.reason).toBe("purge"); + }); + + it("prefers purge window over random chance", () => { + const result = decideSoulEvil({ + config: { + chance: 0, + purge: { at: "00:00", duration: "10m" }, + }, + userTimezone: "UTC", + now: new Date("2026-01-01T00:05:00Z"), + random: () => 0, + }); + expect(result.useEvil).toBe(true); + expect(result.reason).toBe("purge"); + }); + + it("skips purge window when outside duration", () => { + const result = decideSoulEvil({ + config: { + purge: { at: "00:00", duration: "10m" }, + }, + userTimezone: "UTC", + now: new Date("2026-01-01T00:30:00Z"), + }); + expect(result.useEvil).toBe(false); + }); + + it("honors sub-minute purge durations", () => { + const config = { + purge: { at: "00:00", duration: "30s" }, + }; + const active = decideSoulEvil({ + config, + userTimezone: "UTC", + now: new Date("2026-01-01T00:00:20Z"), + }); + const inactive = decideSoulEvil({ + config, + userTimezone: "UTC", + now: new Date("2026-01-01T00:00:40Z"), + }); + expect(active.useEvil).toBe(true); + expect(active.reason).toBe("purge"); + expect(inactive.useEvil).toBe(false); + }); +}); + +describe("applySoulEvilOverride", () => { + it("replaces SOUL content when evil is active and file exists", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-soul-")); + const evilPath = path.join(tempDir, DEFAULT_SOUL_EVIL_FILENAME); + await fs.writeFile(evilPath, "chaotic", "utf-8"); + + const files = makeFiles({ + path: path.join(tempDir, DEFAULT_SOUL_FILENAME), + }); + + const updated = await applySoulEvilOverride({ + files, + workspaceDir: tempDir, + config: { chance: 1 }, + userTimezone: "UTC", + random: () => 0, + }); + + const soul = updated.find((file) => file.name === DEFAULT_SOUL_FILENAME); + expect(soul?.content).toBe("chaotic"); + }); + + it("leaves SOUL content when evil file is missing", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-soul-")); + const files = makeFiles({ + path: path.join(tempDir, DEFAULT_SOUL_FILENAME), + }); + + const updated = await applySoulEvilOverride({ + files, + workspaceDir: tempDir, + config: { chance: 1 }, + userTimezone: "UTC", + random: () => 0, + }); + + const soul = updated.find((file) => file.name === DEFAULT_SOUL_FILENAME); + expect(soul?.content).toBe("friendly"); + }); +}); diff --git a/src/hooks/soul-evil.ts b/src/hooks/soul-evil.ts new file mode 100644 index 000000000..8d377bff2 --- /dev/null +++ b/src/hooks/soul-evil.ts @@ -0,0 +1,209 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { resolveUserTimezone } from "../agents/date-time.js"; +import type { WorkspaceBootstrapFile } from "../agents/workspace.js"; +import { parseDurationMs } from "../cli/parse-duration.js"; +import { resolveUserPath } from "../utils.js"; + +export const DEFAULT_SOUL_EVIL_FILENAME = "SOUL_EVIL.md"; + +export type SoulEvilConfig = { + /** Alternate SOUL file name (default: SOUL_EVIL.md). */ + file?: string; + /** Random chance (0-1) to use SOUL_EVIL on any message. */ + chance?: number; + /** Daily purge window (static time each day). */ + purge?: { + /** Start time in 24h HH:mm format. */ + at?: string; + /** Duration (e.g. 30s, 10m, 1h). */ + duration?: string; + }; +}; + +type SoulEvilDecision = { + useEvil: boolean; + reason?: "purge" | "chance"; + fileName: string; +}; + +type SoulEvilCheckParams = { + config?: SoulEvilConfig; + userTimezone?: string; + now?: Date; + random?: () => number; +}; + +type SoulEvilLog = { + debug?: (message: string) => void; + warn?: (message: string) => void; +}; + +function clampChance(value?: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) return 0; + return Math.min(1, Math.max(0, value)); +} + +function parsePurgeAt(raw?: string): number | null { + if (!raw) return null; + const trimmed = raw.trim(); + const match = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(trimmed); + if (!match) return null; + const hour = Number.parseInt(match[1] ?? "", 10); + const minute = Number.parseInt(match[2] ?? "", 10); + if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null; + return hour * 60 + minute; +} + +function timeOfDayMsInTimezone(date: Date, timeZone: string): number | null { + try { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }).formatToParts(date); + const map: Record = {}; + for (const part of parts) { + if (part.type !== "literal") map[part.type] = part.value; + } + if (!map.hour || !map.minute || !map.second) return null; + const hour = Number.parseInt(map.hour, 10); + const minute = Number.parseInt(map.minute, 10); + const second = Number.parseInt(map.second, 10); + if ( + !Number.isFinite(hour) || + !Number.isFinite(minute) || + !Number.isFinite(second) + ) { + return null; + } + return (hour * 3600 + minute * 60 + second) * 1000 + date.getMilliseconds(); + } catch { + return null; + } +} + +function isWithinDailyPurgeWindow(params: { + at?: string; + duration?: string; + now: Date; + timeZone: string; +}): boolean { + if (!params.at || !params.duration) return false; + const startMinutes = parsePurgeAt(params.at); + if (startMinutes === null) return false; + + let durationMs: number; + try { + durationMs = parseDurationMs(params.duration, { defaultUnit: "m" }); + } catch { + return false; + } + if (!Number.isFinite(durationMs) || durationMs <= 0) return false; + + const dayMs = 24 * 60 * 60 * 1000; + if (durationMs >= dayMs) return true; + + const nowMs = timeOfDayMsInTimezone(params.now, params.timeZone); + if (nowMs === null) return false; + + const startMs = startMinutes * 60 * 1000; + const endMs = startMs + durationMs; + if (endMs < dayMs) { + return nowMs >= startMs && nowMs < endMs; + } + const wrappedEnd = endMs % dayMs; + return nowMs >= startMs || nowMs < wrappedEnd; +} + +export function decideSoulEvil(params: SoulEvilCheckParams): SoulEvilDecision { + const evil = params.config; + const fileName = evil?.file?.trim() || DEFAULT_SOUL_EVIL_FILENAME; + if (!evil) { + return { useEvil: false, fileName }; + } + + const timeZone = resolveUserTimezone(params.userTimezone); + const now = params.now ?? new Date(); + const inPurge = isWithinDailyPurgeWindow({ + at: evil.purge?.at, + duration: evil.purge?.duration, + now, + timeZone, + }); + if (inPurge) { + return { useEvil: true, reason: "purge", fileName }; + } + + const chance = clampChance(evil.chance); + if (chance > 0) { + const random = params.random ?? Math.random; + if (random() < chance) { + return { useEvil: true, reason: "chance", fileName }; + } + } + + return { useEvil: false, fileName }; +} + +export async function applySoulEvilOverride(params: { + files: WorkspaceBootstrapFile[]; + workspaceDir: string; + config?: SoulEvilConfig; + userTimezone?: string; + now?: Date; + random?: () => number; + log?: SoulEvilLog; +}): Promise { + const decision = decideSoulEvil({ + config: params.config, + userTimezone: params.userTimezone, + now: params.now, + random: params.random, + }); + if (!decision.useEvil) return params.files; + + const workspaceDir = resolveUserPath(params.workspaceDir); + const evilPath = path.join(workspaceDir, decision.fileName); + let evilContent: string; + try { + evilContent = await fs.readFile(evilPath, "utf-8"); + } catch { + params.log?.warn?.( + `SOUL_EVIL active (${decision.reason ?? "unknown"}) but file missing: ${evilPath}`, + ); + return params.files; + } + + if (!evilContent.trim()) { + params.log?.warn?.( + `SOUL_EVIL active (${decision.reason ?? "unknown"}) but file empty: ${evilPath}`, + ); + return params.files; + } + + const hasSoulEntry = params.files.some((file) => file.name === "SOUL.md"); + if (!hasSoulEntry) { + params.log?.warn?.( + `SOUL_EVIL active (${decision.reason ?? "unknown"}) but SOUL.md not in bootstrap files`, + ); + return params.files; + } + + let replaced = false; + const updated = params.files.map((file) => { + if (file.name !== "SOUL.md") return file; + replaced = true; + return { ...file, content: evilContent, missing: false }; + }); + if (!replaced) return params.files; + + params.log?.debug?.( + `SOUL_EVIL active (${decision.reason ?? "unknown"}) using ${decision.fileName}`, + ); + + return updated; +}