Files
clawdbot/src/hooks/soul-evil.ts
2026-01-18 06:15:24 +00:00

250 lines
7.3 KiB
TypeScript

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;
};
export function resolveSoulEvilConfigFromHook(
entry: Record<string, unknown> | undefined,
log?: SoulEvilLog,
): SoulEvilConfig | null {
if (!entry) return null;
const file = typeof entry.file === "string" ? entry.file : undefined;
if (entry.file !== undefined && !file) {
log?.warn?.("soul-evil config: file must be a string");
}
let chance: number | undefined;
if (entry.chance !== undefined) {
if (typeof entry.chance === "number" && Number.isFinite(entry.chance)) {
chance = entry.chance;
} else {
log?.warn?.("soul-evil config: chance must be a number");
}
}
let purge: SoulEvilConfig["purge"];
if (entry.purge && typeof entry.purge === "object") {
const at =
typeof (entry.purge as { at?: unknown }).at === "string"
? (entry.purge as { at?: string }).at
: undefined;
const duration =
typeof (entry.purge as { duration?: unknown }).duration === "string"
? (entry.purge as { duration?: string }).duration
: undefined;
if ((entry.purge as { at?: unknown }).at !== undefined && !at) {
log?.warn?.("soul-evil config: purge.at must be a string");
}
if ((entry.purge as { duration?: unknown }).duration !== undefined && !duration) {
log?.warn?.("soul-evil config: purge.duration must be a string");
}
purge = { at, duration };
} else if (entry.purge !== undefined) {
log?.warn?.("soul-evil config: purge must be an object");
}
if (!file && chance === undefined && !purge) return null;
return { file, chance, purge };
}
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<string, string> = {};
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<WorkspaceBootstrapFile[]> {
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;
}